diff --git a/.gitignore b/.gitignore index a446860..98f2766 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ /.idea/navEditor.xml /.idea/assetWizardSettings.xml /.idea/deploymentTargetDropDown.xml +/.idea/kotlinc.xml +/.idea/misc.xml +/buildSrc/build .DS_Store /build /captures diff --git a/.idea/compiler.xml b/.idea/compiler.xml index fb7f4a8..b589d56 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 360e6d4..0ad17cb 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 976b3d2..67c4325 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -110,6 +110,11 @@ dependencies { implementation(Libraries.androidxComposeConstraintLayout) implementation(Libraries.androidxSplashScreen) + implementation(Libraries.androidxGlance) + implementation(Libraries.androidxGlanceAppWidget) + implementation(Libraries.androidxGlanceMaterial) + implementation(Libraries.androidxGlanceMaterial3) + implementation(Libraries.androidxComposeMaterial) implementation(Libraries.material3) implementation(Libraries.material3WindowSizeClass) diff --git a/app/schemas/com.github.pakka_papad.data.AppDatabase/2.json b/app/schemas/com.github.pakka_papad.data.AppDatabase/2.json new file mode 100644 index 0000000..c74395a --- /dev/null +++ b/app/schemas/com.github.pakka_papad.data.AppDatabase/2.json @@ -0,0 +1,625 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "e3253979ea544495dba0fdae8f495fba", + "entities": [ + { + "tableName": "song_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`location` TEXT NOT NULL, `title` TEXT NOT NULL, `album` TEXT NOT NULL DEFAULT 'Unknown', `size` TEXT NOT NULL, `addedDate` TEXT NOT NULL, `modifiedDate` TEXT NOT NULL, `artist` TEXT NOT NULL DEFAULT 'Unknown', `albumArtist` TEXT NOT NULL DEFAULT 'Unknown', `composer` TEXT NOT NULL DEFAULT 'Unknown', `genre` TEXT NOT NULL DEFAULT 'Unknown', `lyricist` TEXT NOT NULL DEFAULT 'Unknown', `year` INTEGER NOT NULL, `comment` TEXT, `durationMillis` INTEGER NOT NULL, `durationFormatted` TEXT NOT NULL, `bitrate` REAL NOT NULL, `sampleRate` REAL NOT NULL, `bitsPerSample` INTEGER NOT NULL, `mimeType` TEXT, `favourite` INTEGER NOT NULL, `artUri` TEXT, `playCount` INTEGER NOT NULL DEFAULT 0, `lastPlayed` INTEGER, PRIMARY KEY(`location`), FOREIGN KEY(`album`) REFERENCES `album_table`(`name`) ON UPDATE NO ACTION ON DELETE SET DEFAULT , FOREIGN KEY(`artist`) REFERENCES `artist_table`(`name`) ON UPDATE NO ACTION ON DELETE SET DEFAULT , FOREIGN KEY(`genre`) REFERENCES `genre_table`(`genre`) ON UPDATE NO ACTION ON DELETE SET DEFAULT , FOREIGN KEY(`albumArtist`) REFERENCES `album_artist_table`(`name`) ON UPDATE NO ACTION ON DELETE SET DEFAULT , FOREIGN KEY(`lyricist`) REFERENCES `lyricist_table`(`name`) ON UPDATE NO ACTION ON DELETE SET DEFAULT , FOREIGN KEY(`composer`) REFERENCES `composer_table`(`name`) ON UPDATE NO ACTION ON DELETE SET DEFAULT )", + "fields": [ + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Unknown'" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedDate", + "columnName": "addedDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modifiedDate", + "columnName": "modifiedDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Unknown'" + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Unknown'" + }, + { + "fieldPath": "composer", + "columnName": "composer", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Unknown'" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Unknown'" + }, + { + "fieldPath": "lyricist", + "columnName": "lyricist", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Unknown'" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationMillis", + "columnName": "durationMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationFormatted", + "columnName": "durationFormatted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bitsPerSample", + "columnName": "bitsPerSample", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "favourite", + "columnName": "favourite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artUri", + "columnName": "artUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastPlayed", + "columnName": "lastPlayed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "location" + ] + }, + "indices": [ + { + "name": "index_song_table_album", + "unique": false, + "columnNames": [ + "album" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_table_album` ON `${TABLE_NAME}` (`album`)" + }, + { + "name": "index_song_table_artist", + "unique": false, + "columnNames": [ + "artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_table_artist` ON `${TABLE_NAME}` (`artist`)" + }, + { + "name": "index_song_table_albumArtist", + "unique": false, + "columnNames": [ + "albumArtist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_table_albumArtist` ON `${TABLE_NAME}` (`albumArtist`)" + }, + { + "name": "index_song_table_composer", + "unique": false, + "columnNames": [ + "composer" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_table_composer` ON `${TABLE_NAME}` (`composer`)" + }, + { + "name": "index_song_table_genre", + "unique": false, + "columnNames": [ + "genre" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_table_genre` ON `${TABLE_NAME}` (`genre`)" + }, + { + "name": "index_song_table_lyricist", + "unique": false, + "columnNames": [ + "lyricist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_table_lyricist` ON `${TABLE_NAME}` (`lyricist`)" + } + ], + "foreignKeys": [ + { + "table": "album_table", + "onDelete": "SET DEFAULT", + "onUpdate": "NO ACTION", + "columns": [ + "album" + ], + "referencedColumns": [ + "name" + ] + }, + { + "table": "artist_table", + "onDelete": "SET DEFAULT", + "onUpdate": "NO ACTION", + "columns": [ + "artist" + ], + "referencedColumns": [ + "name" + ] + }, + { + "table": "genre_table", + "onDelete": "SET DEFAULT", + "onUpdate": "NO ACTION", + "columns": [ + "genre" + ], + "referencedColumns": [ + "genre" + ] + }, + { + "table": "album_artist_table", + "onDelete": "SET DEFAULT", + "onUpdate": "NO ACTION", + "columns": [ + "albumArtist" + ], + "referencedColumns": [ + "name" + ] + }, + { + "table": "lyricist_table", + "onDelete": "SET DEFAULT", + "onUpdate": "NO ACTION", + "columns": [ + "lyricist" + ], + "referencedColumns": [ + "name" + ] + }, + { + "table": "composer_table", + "onDelete": "SET DEFAULT", + "onUpdate": "NO ACTION", + "columns": [ + "composer" + ], + "referencedColumns": [ + "name" + ] + } + ] + }, + { + "tableName": "album_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `albumArtUri` TEXT, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtUri", + "columnName": "albumArtUri", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistName` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistName", + "columnName": "playlistName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "playlistId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist_song_cross_ref_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` INTEGER NOT NULL, `location` TEXT NOT NULL, PRIMARY KEY(`playlistId`, `location`), FOREIGN KEY(`playlistId`) REFERENCES `playlist_table`(`playlistId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`location`) REFERENCES `song_table`(`location`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlistId", + "location" + ] + }, + "indices": [ + { + "name": "index_playlist_song_cross_ref_table_location", + "unique": false, + "columnNames": [ + "location" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_cross_ref_table_location` ON `${TABLE_NAME}` (`location`)" + } + ], + "foreignKeys": [ + { + "table": "playlist_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "playlistId" + ] + }, + { + "table": "song_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location" + ], + "referencedColumns": [ + "location" + ] + } + ] + }, + { + "tableName": "genre_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`genre` TEXT NOT NULL, PRIMARY KEY(`genre`))", + "fields": [ + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "genre" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album_artist_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "composer_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyricist_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "blacklist_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`location` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, PRIMARY KEY(`location`))", + "fields": [ + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "location" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "blacklisted_folder_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`path` TEXT NOT NULL, PRIMARY KEY(`path`))", + "fields": [ + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "path" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "play_history_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songLocation` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playDuration` INTEGER NOT NULL, FOREIGN KEY(`songLocation`) REFERENCES `song_table`(`location`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songLocation", + "columnName": "songLocation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playDuration", + "columnName": "playDuration", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_play_history_table_songLocation", + "unique": false, + "columnNames": [ + "songLocation" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_history_table_songLocation` ON `${TABLE_NAME}` (`songLocation`)" + } + ], + "foreignKeys": [ + { + "table": "song_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songLocation" + ], + "referencedColumns": [ + "location" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e3253979ea544495dba0fdae8f495fba')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index db27f40..6671a36 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/changelogs.json b/app/src/main/assets/changelogs.json index b5ac258..7a39b56 100644 --- a/app/src/main/assets/changelogs.json +++ b/app/src/main/assets/changelogs.json @@ -22,5 +22,17 @@ "Added option to select repeat mode", "Some UI improvements" ] + }, + { + "versionCode": 10200, + "versionName": "1.2.0", + "date": "2 December, 2023", + "changes": [ + "Added folder tab", + "Added option to blacklist folder", + "Updated settings screen UI", + "Added sort option", + "Added basic widget (experimental)" + ] } ] \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/Constants.kt b/app/src/main/java/com/github/pakka_papad/Constants.kt index d746a2f..1b6b5e6 100644 --- a/app/src/main/java/com/github/pakka_papad/Constants.kt +++ b/app/src/main/java/com/github/pakka_papad/Constants.kt @@ -12,7 +12,9 @@ object Constants { const val ALBUM_ARTIST_TABLE = "album_artist_table" const val COMPOSER_TABLE = "composer_table" const val LYRICIST_TABLE = "lyricist_table" - const val BLACKLIST_TABLE = "blacklist_table" + const val BLACKLIST_TABLE = "blacklist_table" // for songs + const val BLACKLISTED_FOLDER_TABLE = "blacklisted_folder_table" + const val PLAY_HISTORY_TABLE = "play_history_table" } const val PACKAGE_NAME = "com.github.pakka_papad" } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/Screens.kt b/app/src/main/java/com/github/pakka_papad/Screens.kt index 7a4889a..f7e1aae 100644 --- a/app/src/main/java/com/github/pakka_papad/Screens.kt +++ b/app/src/main/java/com/github/pakka_papad/Screens.kt @@ -4,6 +4,9 @@ import android.os.Parcelable import androidx.annotation.DrawableRes import kotlinx.parcelize.Parcelize +/** + * !! Dot not change order of already added objects + */ @Parcelize enum class Screens(@DrawableRes val outlinedIcon: Int, @DrawableRes val filledIcon: Int): Parcelable { Songs(R.drawable.ic_outline_music_note_40,R.drawable.ic_baseline_music_note_40), @@ -11,4 +14,5 @@ enum class Screens(@DrawableRes val outlinedIcon: Int, @DrawableRes val filledIc Artists(R.drawable.ic_outline_person_40,R.drawable.ic_baseline_person_40), Playlists(R.drawable.ic_outline_library_music_40,R.drawable.ic_baseline_library_music_40), Genres(R.drawable.ic_baseline_piano_40,R.drawable.ic_baseline_piano_40), + Folders(R.drawable.ic_outline_folder_40,R.drawable.ic_baseline_folder_40) } diff --git a/app/src/main/java/com/github/pakka_papad/Util.kt b/app/src/main/java/com/github/pakka_papad/Util.kt index 69b5578..d8a22e1 100644 --- a/app/src/main/java/com/github/pakka_papad/Util.kt +++ b/app/src/main/java/com/github/pakka_papad/Util.kt @@ -18,7 +18,7 @@ fun Float.toMBfromB(): String{ return "${mb.round(2)} MB" } -val dateFormat = SimpleDateFormat("dd:MM:yyyy") +val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()) fun Long.formatToDate(): String { val calender = Calendar.getInstance().apply { diff --git a/app/src/main/java/com/github/pakka_papad/collection/CollectionActions.kt b/app/src/main/java/com/github/pakka_papad/collection/CollectionActions.kt index 1f4c63b..bf19166 100644 --- a/app/src/main/java/com/github/pakka_papad/collection/CollectionActions.kt +++ b/app/src/main/java/com/github/pakka_papad/collection/CollectionActions.kt @@ -26,4 +26,11 @@ sealed class CollectionActions( text = "Add all to playlist", icon = R.drawable.ic_baseline_playlist_add_40 ) + + data class Sort(override val onClick: () -> Unit) : + CollectionActions( + onClick = onClick, + text = "Sort", + icon = R.drawable.ic_baseline_sort_40 + ) } diff --git a/app/src/main/java/com/github/pakka_papad/collection/CollectionFragment.kt b/app/src/main/java/com/github/pakka_papad/collection/CollectionFragment.kt index a3aaff0..da0988d 100644 --- a/app/src/main/java/com/github/pakka_papad/collection/CollectionFragment.kt +++ b/app/src/main/java/com/github/pakka_papad/collection/CollectionFragment.kt @@ -34,6 +34,8 @@ import androidx.palette.graphics.Palette import coil.compose.AsyncImage import com.github.pakka_papad.R import com.github.pakka_papad.components.FullScreenSadMessage +import com.github.pakka_papad.components.SortOptionChooser +import com.github.pakka_papad.components.SortOptions import com.github.pakka_papad.data.ZenPreferenceProvider import com.github.pakka_papad.data.music.Song import com.github.pakka_papad.ui.theme.ZenTheme @@ -63,6 +65,15 @@ class CollectionFragment : Fragment() { navController.popBackStack() } viewModel.loadCollection(args.collectionType) + val sortOptions = listOf( + SortOptions.Default, + SortOptions.TitleASC, + SortOptions.TitleDSC, + SortOptions.YearASC, + SortOptions.YearDSC, + SortOptions.DurationASC, + SortOptions.DurationDSC, + ) return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -82,6 +93,8 @@ class CollectionFragment : Fragment() { && songsListState.firstVisibleItemScrollOffset <= 10) 0f else 1f } } + var showSortOptions by remember { mutableStateOf(false) } + val chosenSortOrder by viewModel.chosenSortOrder.collectAsStateWithLifecycle() Box { LazyColumn( contentPadding = insetsPadding, @@ -183,10 +196,26 @@ class CollectionFragment : Fragment() { }, CollectionActions.AddToPlaylist { addAllSongsToPlaylistClicked(collectionUi?.songs) + }, + CollectionActions.Sort { + showSortOptions = true } ) ) } + if (showSortOptions){ + SortOptionChooser( + options = sortOptions, + selectedOption = chosenSortOrder, + onOptionSelect = { option -> + viewModel.updateSortOrder(option) + showSortOptions = false + }, + onChooserDismiss = { + showSortOptions = false + } + ) + } } } } diff --git a/app/src/main/java/com/github/pakka_papad/collection/CollectionViewModel.kt b/app/src/main/java/com/github/pakka_papad/collection/CollectionViewModel.kt index 5b88c4c..23f7bb2 100644 --- a/app/src/main/java/com/github/pakka_papad/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/pakka_papad/collection/CollectionViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.pakka_papad.components.SortOptions import com.github.pakka_papad.data.DataManager import com.github.pakka_papad.data.music.PlaylistSongCrossRef import com.github.pakka_papad.data.music.Song @@ -26,6 +27,9 @@ class CollectionViewModel @Inject constructor( private val _collectionType = MutableStateFlow(null) + private val _chosenSortOrder = MutableStateFlow(SortOptions.Default.ordinal) + val chosenSortOrder = _chosenSortOrder.asStateFlow() + @OptIn(ExperimentalCoroutinesApi::class) val collectionUi = _collectionType .flatMapLatest { type -> @@ -125,6 +129,28 @@ class CollectionViewModel @Inject constructor( } else -> flow { } } + }.combine(_chosenSortOrder) { ui, sortOrder -> + when(sortOrder){ + SortOptions.TitleASC.ordinal -> { + ui.copy(songs = ui.songs.sortedBy { it.title }) + } + SortOptions.TitleDSC.ordinal -> { + ui.copy(songs = ui.songs.sortedByDescending { it.title }) + } + SortOptions.YearASC.ordinal -> { + ui.copy(songs = ui.songs.sortedBy { it.year }) + } + SortOptions.YearDSC.ordinal -> { + ui.copy(songs = ui.songs.sortedByDescending { it.year }) + } + SortOptions.DurationASC.ordinal -> { + ui.copy(songs = ui.songs.sortedBy { it.durationMillis }) + } + SortOptions.DurationDSC.ordinal -> { + ui.copy(songs = ui.songs.sortedByDescending { it.durationMillis }) + } + else -> ui + } }.catch { exception -> Timber.e(exception) }.stateIn( @@ -194,4 +220,8 @@ class CollectionViewModel @Inject constructor( } } + fun updateSortOrder(order: Int){ + _chosenSortOrder.update { order } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/components/Chips.kt b/app/src/main/java/com/github/pakka_papad/components/Chips.kt new file mode 100644 index 0000000..cd53529 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/components/Chips.kt @@ -0,0 +1,107 @@ +package com.github.pakka_papad.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.KeyboardArrowRight +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +enum class SortType(val reversible: Boolean = false) { + Title(true), + Album(true), + Artist(true), + Length(true), + Liked(false) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SortChip( + sortOptions: List, + onSortSelected: (sortType: SortType, reversed: Boolean?) -> Unit, + modifier: Modifier = Modifier, +) { + var menuPageNumber by remember { mutableStateOf(0) } + var selectedType by remember { mutableStateOf(SortType.Title) } + AssistChip( + onClick = { menuPageNumber = 1 }, + label = { + Text( + text = "Sort by", + style = MaterialTheme.typography.labelLarge + ) + }, + modifier = modifier, + trailingIcon = { + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = "down arrow", + modifier = Modifier.size(18.dp) + ) + } + ) + DropdownMenu( + expanded = menuPageNumber != 0, + onDismissRequest = { + menuPageNumber = 0 + } + ) { + when (menuPageNumber) { + 1 -> { + sortOptions.forEach { + DropdownMenuItem( + text = { + Text( + text = it.name + ) + }, + onClick = { + selectedType = it + if (it.reversible) { + menuPageNumber = 2 + } else { + onSortSelected(selectedType, null) + menuPageNumber = 0 + } + }, + trailingIcon = { + if (it.reversible) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowRight, + contentDescription = "right arrow" + ) + } + } + ) + } + } + 2 -> { + DropdownMenuItem( + text = { + Text( + text = "Ascending" + ) + }, + onClick = { + onSortSelected(selectedType, false) + menuPageNumber = 0 + } + ) + DropdownMenuItem( + text = { + Text( + text = "Descending" + ) + }, + onClick = { + onSortSelected(selectedType, true) + menuPageNumber = 0 + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/components/SelectableCards.kt b/app/src/main/java/com/github/pakka_papad/components/SelectableCards.kt new file mode 100644 index 0000000..de6dc69 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/components/SelectableCards.kt @@ -0,0 +1,34 @@ +package com.github.pakka_papad.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun SelectableCard( + isSelected: Boolean, + onSelectChange: (isSelected: Boolean) -> Unit, + content: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, +){ + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ){ + Checkbox( + checked = isSelected, + onCheckedChange = onSelectChange + ) + content() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/components/SongCards.kt b/app/src/main/java/com/github/pakka_papad/components/SongCards.kt index 8534e94..fba9efb 100644 --- a/app/src/main/java/com/github/pakka_papad/components/SongCards.kt +++ b/app/src/main/java/com/github/pakka_papad/components/SongCards.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.github.pakka_papad.components.more_options.OptionsAlertDialog import com.github.pakka_papad.components.more_options.SongOptions +import com.github.pakka_papad.data.music.MiniSong import com.github.pakka_papad.data.music.Song import kotlinx.coroutines.launch @@ -238,4 +239,87 @@ fun SongCardV3( style = MaterialTheme.typography.titleSmall, overflow = TextOverflow.Ellipsis ) +} + +@Composable +fun MiniSongCard( + song: MiniSong, + onSongClicked: () -> Unit, + currentlyPlaying: Boolean = false, + songOptions: List, +) { + val iconModifier = Modifier.size(26.dp) + val spacerModifier = Modifier.width(10.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .height(70.dp) + .clickable(onClick = onSongClicked) + .background(if (currentlyPlaying) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = song.artUri, + contentDescription = "song-${song.title}-art", + modifier = Modifier + .size(50.dp) + .clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Crop + ) + Spacer(spacerModifier) + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.Center + ) { + Text( + text = song.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + modifier = Modifier.fillMaxWidth(), + color = if (currentlyPlaying) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = song.artist, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.fillMaxWidth(), + color = if (currentlyPlaying) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, + ) + } + if (songOptions.isNotEmpty()) { + Spacer(spacerModifier) + var optionsVisible by remember { mutableStateOf(false) } + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = null, + modifier = iconModifier + .clickable( + onClick = { + optionsVisible = true + }, + indication = rememberRipple( + bounded = false, + radius = 20.dp + ), + interactionSource = remember { MutableInteractionSource() } + ), + tint = if (currentlyPlaying) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + ) + if (optionsVisible) { + OptionsAlertDialog( + options = songOptions, + title = song.title, + onDismissRequest = { + optionsVisible = false + } + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/components/SortOptions.kt b/app/src/main/java/com/github/pakka_papad/components/SortOptions.kt new file mode 100644 index 0000000..2f29123 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/components/SortOptions.kt @@ -0,0 +1,146 @@ +package com.github.pakka_papad.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import com.github.pakka_papad.Screens + +/** + * Do not change order + */ +enum class SortOptions( + val text: String, +) { + Default(text = "Default"), + TitleASC(text = "Title - Ascending"), + TitleDSC(text = "Title - Descending"), + ArtistASC(text = "Artist - Descending"), + ArtistDSC(text = "Artist - Descending"), + AlbumASC(text = "Album - Ascending"), + AlbumDSC(text = "Album - Descending"), + YearASC(text = "Year - Ascending"), + YearDSC(text = "Year - Descending"), + DurationASC(text = "Duration - Ascending"), + DurationDSC(text = "Duration - Descending"), + NameASC(text = "Name - Ascending"), + NameDSC(text = "Name - Descending"), + SongsCountASC(text = "Songs count - Ascending"), + SongsCountDSC(text = "Songs count - Descending"), +} + +fun Screens.getSortOptions(): List { + return when (this) { + Screens.Songs -> listOf( + SortOptions.TitleASC, + SortOptions.TitleDSC, + SortOptions.AlbumASC, + SortOptions.AlbumDSC, + SortOptions.ArtistASC, + SortOptions.ArtistDSC, + SortOptions.YearASC, + SortOptions.YearDSC, + SortOptions.DurationASC, + SortOptions.DurationDSC, + ) + + Screens.Albums -> listOf( + SortOptions.TitleASC, + SortOptions.TitleDSC, + ) + + Screens.Artists, Screens.Genres, Screens.Playlists -> listOf( + SortOptions.NameASC, + SortOptions.NameDSC, + SortOptions.SongsCountASC, + SortOptions.SongsCountDSC, + ) + + Screens.Folders -> listOf( + SortOptions.Default, + SortOptions.NameASC, + SortOptions.NameDSC, + ) + } +} + +@Composable +fun SortOptionChooser( + options: List, + selectedOption: Int, + onOptionSelect: (Int) -> Unit, + onChooserDismiss: () -> Unit, +) { + if (options.isEmpty()) return + AlertDialog( + onDismissRequest = onChooserDismiss, + title = { + Text( + text = "Sort", + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + Button( + onClick = onChooserDismiss, + ) { + Text( + text = "Close", + style = MaterialTheme.typography.bodyLarge, + ) + } + }, + text = { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + items( + items = options, + key = { option -> option.ordinal } + ) { option -> + SortOptionCard( + option = option, + isSelected = (selectedOption == option.ordinal), + onSelect = { onOptionSelect(option.ordinal) } + ) + } + } + } + ) +} + +@Composable +private fun SortOptionCard( + option: SortOptions, + isSelected: Boolean, + onSelect: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onSelect), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = onSelect + ) + Text( + text = option.text, + style = MaterialTheme.typography.titleMedium + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/components/TopAppBars.kt b/app/src/main/java/com/github/pakka_papad/components/TopAppBars.kt index 21be4fa..a8d11d0 100644 --- a/app/src/main/java/com/github/pakka_papad/components/TopAppBars.kt +++ b/app/src/main/java/com/github/pakka_papad/components/TopAppBars.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -143,4 +145,51 @@ fun TopBarWithBackArrow( actions = actions, backgroundColor = backgroundColor, titleMaxLines = titleMaxLines, +) + + +@Composable +fun CancelConfirmTopBar( + onCancelClicked: () -> Unit, + onConfirmClicked: () -> Unit, + title: String, +) = CenterAlignedTopBar( + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + modifier = Modifier + .padding(16.dp) + .size(30.dp) + .clickable( + interactionSource = remember{ MutableInteractionSource() }, + indication = rememberRipple( + bounded = false, + radius = 25.dp, + ), + onClick = onCancelClicked + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + title = title, + actions = { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null, + modifier = Modifier + .padding(16.dp) + .size(30.dp) + .clickable( + interactionSource = remember{ MutableInteractionSource() }, + indication = rememberRipple( + bounded = false, + radius = 25.dp, + ), + onClick = onConfirmClicked + ), + tint = MaterialTheme.colorScheme.onSurface + ) + }, + titleMaxLines = 1 ) \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/components/more_options/FolderOptions.kt b/app/src/main/java/com/github/pakka_papad/components/more_options/FolderOptions.kt new file mode 100644 index 0000000..10e4863 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/components/more_options/FolderOptions.kt @@ -0,0 +1,21 @@ +package com.github.pakka_papad.components.more_options + +import androidx.annotation.DrawableRes +import com.github.pakka_papad.R + +sealed class FolderOptions( + override val onClick: () -> Unit, + override val text: String, + @DrawableRes override val icon: Int, +) : MoreOptions( + onClick = onClick, + text = text, + icon = icon, +){ + data class Blacklist(override val onClick: () -> Unit): + FolderOptions( + onClick = onClick, + text = "Blacklist Folder", + icon = R.drawable.ic_baseline_remove_circle_40, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/components/more_options/OptionsDropDown.kt b/app/src/main/java/com/github/pakka_papad/components/more_options/OptionsDropDown.kt index 045bfaa..4d0712c 100644 --- a/app/src/main/java/com/github/pakka_papad/components/more_options/OptionsDropDown.kt +++ b/app/src/main/java/com/github/pakka_papad/components/more_options/OptionsDropDown.kt @@ -20,31 +20,33 @@ fun OptionsDropDown( offset: DpOffset = DpOffset(0.dp, 0.dp), ) { if (options.isEmpty()) return - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - offset = offset, - ) { - options.forEach { option -> - DropdownMenuItem( - onClick = { - onDismissRequest() - option.onClick() - }, - text = { - Text( - text = option.text, - style = MaterialTheme.typography.bodyMedium, - ) - }, - leadingIcon = { - Icon( - painter = painterResource(option.icon), - modifier = Modifier.size(24.dp), - contentDescription = option.text - ) - } - ) + MaterialTheme(shapes = MaterialTheme.shapes.copy(extraSmall = MaterialTheme.shapes.large)) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + offset = offset, + ) { + options.forEach { option -> + DropdownMenuItem( + onClick = { + onDismissRequest() + option.onClick() + }, + text = { + Text( + text = option.text, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingIcon = { + Icon( + painter = painterResource(option.icon), + modifier = Modifier.size(24.dp), + contentDescription = option.text + ) + } + ) + } } } } diff --git a/app/src/main/java/com/github/pakka_papad/data/AppDatabase.kt b/app/src/main/java/com/github/pakka_papad/data/AppDatabase.kt index 6eb662e..34e632a 100644 --- a/app/src/main/java/com/github/pakka_papad/data/AppDatabase.kt +++ b/app/src/main/java/com/github/pakka_papad/data/AppDatabase.kt @@ -1,7 +1,10 @@ package com.github.pakka_papad.data +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase +import com.github.pakka_papad.data.analytics.PlayHistory +import com.github.pakka_papad.data.analytics.PlayHistoryDao import com.github.pakka_papad.data.daos.* import com.github.pakka_papad.data.music.* @@ -15,9 +18,15 @@ import com.github.pakka_papad.data.music.* AlbumArtist::class, Composer::class, Lyricist::class, - BlacklistedSong::class + BlacklistedSong::class, + BlacklistedFolder::class, + PlayHistory::class, ], - version = 1, exportSchema = true) + version = 2, exportSchema = true, + autoMigrations = [ + AutoMigration(from = 1, to = 2) + ] +) abstract class AppDatabase: RoomDatabase() { abstract fun songDao(): SongDao @@ -38,4 +47,8 @@ abstract class AppDatabase: RoomDatabase() { abstract fun blacklistDao(): BlacklistDao + abstract fun blacklistedFolderDao(): BlacklistedFolderDao + + abstract fun playHistoryDao(): PlayHistoryDao + } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/DataManager.kt b/app/src/main/java/com/github/pakka_papad/data/DataManager.kt index 0cee33c..c1258d3 100644 --- a/app/src/main/java/com/github/pakka_papad/data/DataManager.kt +++ b/app/src/main/java/com/github/pakka_papad/data/DataManager.kt @@ -1,38 +1,33 @@ package com.github.pakka_papad.data -import android.content.ContentUris import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Build -import android.provider.MediaStore.Audio import android.widget.Toast import androidx.compose.runtime.mutableStateListOf import com.github.pakka_papad.data.components.* import com.github.pakka_papad.data.music.* import com.github.pakka_papad.data.notification.ZenNotificationManager -import com.github.pakka_papad.formatToDate import com.github.pakka_papad.nowplaying.RepeatMode import com.github.pakka_papad.player.ZenPlayer -import com.github.pakka_papad.toMBfromB -import com.github.pakka_papad.toMS import kotlinx.coroutines.* import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import timber.log.Timber import java.io.File -import java.io.FileNotFoundException -import java.util.* +import kotlin.collections.HashSet class DataManager( private val context: Context, private val notificationManager: ZenNotificationManager, private val daoCollection: DaoCollection, private val scope: CoroutineScope, + private val songExtractor: SongExtractor, ) { val getAll by lazy { GetAll(daoCollection) } @@ -41,21 +36,43 @@ class DataManager( val querySearch by lazy { QuerySearch(daoCollection) } + val blacklistedSongLocations = HashSet() + val blacklistedFolderPaths = HashSet() + init { cleanData() + buildBlacklistStore() } fun cleanData() { scope.launch { + val jobs = mutableListOf() daoCollection.songDao.getSongs().forEach { try { if(!File(it.location).exists()){ - launch { daoCollection.songDao.deleteSong(it) } + jobs += launch { daoCollection.songDao.deleteSong(it) } } } catch (_: Exception){ } } + jobs.joinAll() + jobs.clear() + daoCollection.albumDao.cleanAlbumTable() + daoCollection.artistDao.cleanArtistTable() + daoCollection.albumArtistDao.cleanAlbumArtistTable() + daoCollection.composerDao.cleanComposerTable() + daoCollection.lyricistDao.cleanLyricistTable() + daoCollection.genreDao.cleanGenreTable() + } + } + + private fun buildBlacklistStore(){ + scope.launch { + val blacklistedSongs = daoCollection.blacklistDao.getBlacklistedSongs() + blacklistedSongs.forEach { blacklistedSongLocations.add(it.location) } + val blacklistedFolders = daoCollection.blacklistedFolderDao.getAllFolders().first() + blacklistedFolders.forEach { blacklistedFolderPaths.add(it.path) } } } @@ -63,6 +80,23 @@ class DataManager( data.forEach { Timber.d("bs: $it") daoCollection.blacklistDao.deleteBlacklistedSong(it) + blacklistedSongLocations.remove(it.location) + } + } + + suspend fun addFolderToBlacklist(path: String){ + daoCollection.songDao.deleteSongsWithPathPrefix(path) + daoCollection.blacklistedFolderDao.insertFolder(BlacklistedFolder(path)) + blacklistedFolderPaths.add(path) + cleanData() + } + + suspend fun removeFoldersFromBlacklist(folders: List){ + folders.forEach { folder -> + try { + daoCollection.blacklistedFolderDao.deleteFolder(folder) + blacklistedFolderPaths.remove(folder.path) + } catch (_: Exception){ } } } @@ -86,6 +120,7 @@ class DataManager( artist = song.artist, ) ) + blacklistedSongLocations.add(song.location) } suspend fun insertPlaylistSongCrossRefs(playlistSongCrossRefs: List) = @@ -99,103 +134,25 @@ class DataManager( fun scanForMusic() = scope.launch { _scanStatus.send(ScanStatus.ScanStarted) -// notificationManager.sendScanningNotification() - val blacklistedSongs = daoCollection.blacklistDao.getBlacklistedSongs() - val blacklistedSongLocations = blacklistedSongs.map { it.location }.toSet() - val selection = Audio.Media.IS_MUSIC + " != 0" - val projection = arrayOf( - Audio.Media.DATA, - Audio.Media.TITLE, - Audio.Media.ALBUM_ID, - Audio.Media.ALBUM, - Audio.Media.SIZE, - Audio.Media.DATE_ADDED, - Audio.Media.DATE_MODIFIED, - Audio.Media._ID - ) - val cursor = context.contentResolver.query( - Audio.Media.EXTERNAL_CONTENT_URI, - projection, - selection, - null, - Audio.Media.DATE_ADDED, - null - ) ?: return@launch - val totalSongs = cursor.count - var parsedSongs = 0 - cursor.moveToFirst() - val mExtractor = MetadataExtractor() - val dataIndex = cursor.getColumnIndex(Audio.Media.DATA) - val titleIndex = cursor.getColumnIndex(Audio.Media.TITLE) - val albumIdIndex = cursor.getColumnIndex(Audio.Media.ALBUM_ID) - val albumIndex = cursor.getColumnIndex(Audio.Media.ALBUM) - val sizeIndex = cursor.getColumnIndex(Audio.Media.SIZE) - val dateAddedIndex = cursor.getColumnIndex(Audio.Media.DATE_ADDED) - val dateModifiedIndex = cursor.getColumnIndex(Audio.Media.DATE_MODIFIED) - val songIdIndex = cursor.getColumnIndex(Audio.Media._ID) - val songCover = Uri.parse("content://media/external/audio/albumart") - - val songs = ArrayList() - val albumArtMap = HashMap() - val artistSet = TreeSet() - val albumArtistSet = TreeSet() - val composerSet = TreeSet() - val genreSet = TreeSet() - val lyricistSet = TreeSet() - do { - try { - val file = File(cursor.getString(dataIndex)) - if (blacklistedSongLocations.contains(file.path)) continue - if (!file.exists()) throw FileNotFoundException() - val songMetadata = mExtractor.getSongMetadata(file.path) - val song = Song( - location = file.path, - title = cursor.getString(titleIndex), - album = cursor.getString(albumIndex).trim(), - size = cursor.getFloat(sizeIndex).toMBfromB(), - addedDate = cursor.getString(dateAddedIndex).toLong().formatToDate(), - modifiedDate = cursor.getString(dateModifiedIndex).toLong().formatToDate(), - artist = songMetadata.artist.trim(), - albumArtist = songMetadata.albumArtist.trim(), - composer = songMetadata.composer.trim(), - genre = songMetadata.genre.trim(), - lyricist = songMetadata.lyricist.trim(), - year = songMetadata.year, - comment = songMetadata.comment, - durationMillis = songMetadata.duration, - durationFormatted = songMetadata.duration.toMS(), - bitrate = songMetadata.bitrate, - sampleRate = songMetadata.sampleRate, - bitsPerSample = songMetadata.bitsPerSample, - mimeType = songMetadata.mimeType, - artUri = "content://media/external/audio/media/${cursor.getLong(songIdIndex)}/albumart" - ) - songs.add(song) - artistSet.add(song.artist) - albumArtistSet.add(song.albumArtist) - composerSet.add(song.composer) - lyricistSet.add(song.lyricist) - genreSet.add(song.genre) - if (albumArtMap[song.album] == null) { - albumArtMap[song.album] = - ContentUris.withAppendedId(songCover, cursor.getLong(albumIdIndex)) - .toString() - } - } catch (e: Exception) { - Timber.e(e.message ?: e.localizedMessage ?: "FILE_DOES_NOT_EXIST") + val (songs, albums) = songExtractor.extract( + blacklistedSongLocations, + blacklistedFolderPaths, + statusListener = { parsed, total -> + _scanStatus.trySend(ScanStatus.ScanProgress(parsed, total)) } - parsedSongs++ - _scanStatus.send(ScanStatus.ScanProgress(parsedSongs, totalSongs)) - } while (cursor.moveToNext()) - cursor.close() - daoCollection.albumDao.insertAllAlbums(albumArtMap.entries.map { (t, u) -> Album(t, u) }) - daoCollection.artistDao.insertAllArtists(artistSet.map { Artist(it) }) - daoCollection.albumArtistDao.insertAllAlbumArtists(albumArtistSet.map { AlbumArtist(it) }) - daoCollection.composerDao.insertAllComposers(composerSet.map { Composer(it) }) - daoCollection.lyricistDao.insertAllLyricists(lyricistSet.map { Lyricist(it) }) - daoCollection.genreDao.insertAllGenres(genreSet.map { Genre(it) }) + ) + val artists = songs.map { it.artist }.toSet().map { Artist(it) } + val albumArtists = songs.map { it.albumArtist }.toSet().map { AlbumArtist(it) } + val lyricists = songs.map { it.lyricist }.toSet().map { Lyricist(it) } + val composers = songs.map { it.composer }.toSet().map { Composer(it) } + val genres = songs.map { it.genre }.toSet().map { Genre(it) } + daoCollection.albumDao.insertAllAlbums(albums) + daoCollection.artistDao.insertAllArtists(artists) + daoCollection.albumArtistDao.insertAllAlbumArtists(albumArtists) + daoCollection.lyricistDao.insertAllLyricists(lyricists) + daoCollection.composerDao.insertAllComposers(composers) + daoCollection.genreDao.insertAllGenres(genres) daoCollection.songDao.insertAllSongs(songs) -// notificationManager.removeScanningNotification() _scanStatus.send(ScanStatus.ScanComplete) } @@ -239,6 +196,7 @@ class DataManager( private var remIdx = 0 @Synchronized + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) fun setQueue(newQueue: List, startPlayingFromIndex: Int) { if (newQueue.isEmpty()) return _queue.apply { @@ -296,4 +254,14 @@ class DataManager( fun addToQueue(song: Song) fun updateNotification() } + + fun addPlayHistory(songLocation: String, duration: Long){ + scope.launch { + try { + daoCollection.playHistoryDao.addRecord(songLocation, duration) + } catch (_: Exception){ + + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/UserPreferencesSerializer.kt b/app/src/main/java/com/github/pakka_papad/data/UserPreferencesSerializer.kt index 2b34779..95eab50 100644 --- a/app/src/main/java/com/github/pakka_papad/data/UserPreferencesSerializer.kt +++ b/app/src/main/java/com/github/pakka_papad/data/UserPreferencesSerializer.kt @@ -1,6 +1,8 @@ package com.github.pakka_papad.data import androidx.datastore.core.Serializer +import com.github.pakka_papad.Screens +import com.github.pakka_papad.components.SortOptions import java.io.InputStream import java.io.OutputStream import javax.inject.Inject @@ -18,6 +20,19 @@ class UserPreferencesSerializer @Inject constructor() : Serializer){ + if (tabsList.isEmpty()) return + coroutineScope.launch { + userPreferences.updateData { + it.copy { + selectedTabs.apply { + clear() + addAll(tabsList) + } + } + } + } + } + + val songSortOrder = userPreferences.data + .map { + it.getChosenSortOrderOrDefault(Screens.Songs.ordinal, SortOptions.TitleASC.ordinal) + } + + val albumSortOrder = userPreferences.data + .map { + it.getChosenSortOrderOrDefault(Screens.Albums.ordinal, SortOptions.TitleASC.ordinal) + } + + val artistSortOrder = userPreferences.data + .map { + it.getChosenSortOrderOrDefault(Screens.Artists.ordinal, SortOptions.NameASC.ordinal) + } + + val playlistSortOrder = userPreferences.data + .map { + it.getChosenSortOrderOrDefault(Screens.Playlists.ordinal, SortOptions.NameASC.ordinal) + } + + val genreSortOrder = userPreferences.data + .map { + it.getChosenSortOrderOrDefault(Screens.Genres.ordinal, SortOptions.NameASC.ordinal) + } + + val folderSortOrder = userPreferences.data + .map { + it.getChosenSortOrderOrDefault(Screens.Folders.ordinal, SortOptions.Default.ordinal) + } + + val sortOrder = userPreferences.data + .map { + it.chosenSortOrderMap + } + .stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = mapOf(), + ) + + fun updateSortOrder(screen: Int, order: Int) { + coroutineScope.launch { + userPreferences.updateData { + it.copy { + chosenSortOrder[screen] = order + } + } + } + } + init { val initJob = coroutineScope.launch { launch { theme.collect { } } launch { isOnBoardingComplete.collect { } } launch { isCrashlyticsDisabled.collect { } } launch { playbackParams.collect { updatePlaybackParams(it.playbackSpeed,it.playbackPitch) } } + launch { selectedTabs.collect{ } } + launch { songSortOrder.collect { } } + launch { albumSortOrder.collect { } } + launch { artistSortOrder.collect { } } + launch { playlistSortOrder.collect { } } + launch { genreSortOrder.collect { } } + launch { folderSortOrder.collect { } } + launch { sortOrder.collect { } } } coroutineScope.launch { delay(1.minutes) diff --git a/app/src/main/java/com/github/pakka_papad/data/analytics/PlayHistory.kt b/app/src/main/java/com/github/pakka_papad/data/analytics/PlayHistory.kt new file mode 100644 index 0000000..23ad430 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/data/analytics/PlayHistory.kt @@ -0,0 +1,26 @@ +package com.github.pakka_papad.data.analytics + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.github.pakka_papad.Constants +import com.github.pakka_papad.data.music.Song + +@Entity( + tableName = Constants.Tables.PLAY_HISTORY_TABLE, + foreignKeys = [ + ForeignKey( + entity = Song::class, + parentColumns = ["location"], + childColumns = ["songLocation"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class PlayHistory( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songLocation: String, + val timestamp: Long, + val playDuration: Long, +) diff --git a/app/src/main/java/com/github/pakka_papad/data/analytics/PlayHistoryDao.kt b/app/src/main/java/com/github/pakka_papad/data/analytics/PlayHistoryDao.kt new file mode 100644 index 0000000..9066805 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/data/analytics/PlayHistoryDao.kt @@ -0,0 +1,40 @@ +package com.github.pakka_papad.data.analytics + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.github.pakka_papad.Constants +import com.github.pakka_papad.data.music.Song + +@Dao +interface PlayHistoryDao { + + @Query( + "SELECT * FROM ${Constants.Tables.SONG_TABLE} WHERE location = :location" + ) + suspend fun getSongFromLocation(location: String): Song? + + @Update + suspend fun updateSong(song: Song) + + @Insert + suspend fun insertRecord(record: PlayHistory) + + @Transaction + suspend fun addRecord(location: String, duration: Long) { + val song = getSongFromLocation(location) ?: return + val time = System.currentTimeMillis() + val updatedSong = song.copy(playCount = 1 + song.playCount, lastPlayed = time) + val record = PlayHistory( + songLocation = location, + timestamp = time, + playDuration = duration, + ) + updateSong(updatedSong) + insertRecord(record) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/components/DaoCollection.kt b/app/src/main/java/com/github/pakka_papad/data/components/DaoCollection.kt index c6465a3..4c96103 100644 --- a/app/src/main/java/com/github/pakka_papad/data/components/DaoCollection.kt +++ b/app/src/main/java/com/github/pakka_papad/data/components/DaoCollection.kt @@ -1,5 +1,6 @@ package com.github.pakka_papad.data.components +import com.github.pakka_papad.data.analytics.PlayHistoryDao import com.github.pakka_papad.data.daos.* data class DaoCollection( @@ -11,5 +12,7 @@ data class DaoCollection( val lyricistDao: LyricistDao, val genreDao: GenreDao, val playlistDao: PlaylistDao, - val blacklistDao: BlacklistDao + val blacklistDao: BlacklistDao, + val blacklistedFolderDao: BlacklistedFolderDao, + val playHistoryDao: PlayHistoryDao, ) \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/components/GetAll.kt b/app/src/main/java/com/github/pakka_papad/data/components/GetAll.kt index 21d7b46..40b4321 100644 --- a/app/src/main/java/com/github/pakka_papad/data/components/GetAll.kt +++ b/app/src/main/java/com/github/pakka_papad/data/components/GetAll.kt @@ -20,4 +20,6 @@ class GetAll( fun genres() = daoCollection.songDao.getAllGenresWithSongCount() fun blacklistedSongs() = daoCollection.blacklistDao.getBlacklistedSongsFlow() + + fun blacklistedFolders() = daoCollection.blacklistedFolderDao.getAllFolders() } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/daos/AlbumArtistDao.kt b/app/src/main/java/com/github/pakka_papad/data/daos/AlbumArtistDao.kt index 95f91d6..5623a7a 100644 --- a/app/src/main/java/com/github/pakka_papad/data/daos/AlbumArtistDao.kt +++ b/app/src/main/java/com/github/pakka_papad/data/daos/AlbumArtistDao.kt @@ -19,4 +19,11 @@ interface AlbumArtistDao { @Query("SELECT * FROM ${Constants.Tables.ALBUM_ARTIST_TABLE} WHERE name = :name") fun getAlbumArtistWithSongs(name: String): Flow + @Transaction + @Query("DELETE FROM ${Constants.Tables.ALBUM_ARTIST_TABLE} WHERE name IN " + + "(SELECT albumArtist.name as name FROM ${Constants.Tables.ALBUM_ARTIST_TABLE} as albumArtist LEFT JOIN " + + "${Constants.Tables.SONG_TABLE} as song ON albumArtist.name = song.albumArtist GROUP BY albumArtist.name " + + "HAVING COUNT(song.location) = 0)") + suspend fun cleanAlbumArtistTable() + } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/daos/AlbumDao.kt b/app/src/main/java/com/github/pakka_papad/data/daos/AlbumDao.kt index 2c585f0..8470832 100644 --- a/app/src/main/java/com/github/pakka_papad/data/daos/AlbumDao.kt +++ b/app/src/main/java/com/github/pakka_papad/data/daos/AlbumDao.kt @@ -25,4 +25,10 @@ interface AlbumDao { @Query("SELECT * FROM ${Constants.Tables.ALBUM_TABLE} WHERE name LIKE '%' || :query || '%'") suspend fun searchAlbums(query: String): List + @Transaction + @Query("DELETE FROM ${Constants.Tables.ALBUM_TABLE} WHERE name IN " + + "(SELECT album.name as name FROM ${Constants.Tables.ALBUM_TABLE} as album LEFT JOIN " + + "${Constants.Tables.SONG_TABLE} as song ON album.name = song.album GROUP BY album.name " + + "HAVING COUNT(song.location) = 0)") + suspend fun cleanAlbumTable() } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/daos/ArtistDao.kt b/app/src/main/java/com/github/pakka_papad/data/daos/ArtistDao.kt index 10bc9a5..8a8edb0 100644 --- a/app/src/main/java/com/github/pakka_papad/data/daos/ArtistDao.kt +++ b/app/src/main/java/com/github/pakka_papad/data/daos/ArtistDao.kt @@ -30,11 +30,11 @@ interface ArtistDao { @Query("SELECT * FROM ${Constants.Tables.ARTIST_TABLE} WHERE name LIKE '%' || :query || '%'") suspend fun searchArtists(query: String): List -// @Transaction -// @Query("SELECT ${Constants.Tables.ARTIST_TABLE}.name as artistName, COUNT(*) as count " + -// "FROM ${Constants.Tables.ARTIST_TABLE} JOIN ${Constants.Tables.SONG_TABLE} ON " + -// "${Constants.Tables.ARTIST_TABLE}.name = ${Constants.Tables.SONG_TABLE}.artist " + -// "GROUP BY ${Constants.Tables.ARTIST_TABLE}.name") -// fun getAllArtistsWithSongCount(): Flow> + @Transaction + @Query("DELETE FROM ${Constants.Tables.ARTIST_TABLE} WHERE name IN " + + "(SELECT artist.name as name FROM ${Constants.Tables.ARTIST_TABLE} as artist LEFT JOIN " + + "${Constants.Tables.SONG_TABLE} as song ON artist.name = song.artist GROUP BY artist.name " + + "HAVING COUNT(song.location) = 0)") + suspend fun cleanArtistTable() } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/daos/BlacklistedFolderDao.kt b/app/src/main/java/com/github/pakka_papad/data/daos/BlacklistedFolderDao.kt new file mode 100644 index 0000000..7434978 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/data/daos/BlacklistedFolderDao.kt @@ -0,0 +1,24 @@ +package com.github.pakka_papad.data.daos + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.pakka_papad.Constants +import com.github.pakka_papad.data.music.BlacklistedFolder +import kotlinx.coroutines.flow.Flow + +@Dao +interface BlacklistedFolderDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertFolder(vararg folder: BlacklistedFolder) + + @Query("SELECT * FROM ${Constants.Tables.BLACKLISTED_FOLDER_TABLE}") + fun getAllFolders(): Flow> + + @Delete + suspend fun deleteFolder(folder: BlacklistedFolder) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/daos/ComposerDao.kt b/app/src/main/java/com/github/pakka_papad/data/daos/ComposerDao.kt index 9c33e4b..6e4c73b 100644 --- a/app/src/main/java/com/github/pakka_papad/data/daos/ComposerDao.kt +++ b/app/src/main/java/com/github/pakka_papad/data/daos/ComposerDao.kt @@ -19,4 +19,10 @@ interface ComposerDao { @Query("SELECT * FROM ${Constants.Tables.COMPOSER_TABLE} WHERE name = :name") fun getComposerWithSongs(name: String): Flow + @Transaction + @Query("DELETE FROM ${Constants.Tables.COMPOSER_TABLE} WHERE name IN " + + "(SELECT composer.name as name FROM ${Constants.Tables.COMPOSER_TABLE} as composer LEFT JOIN " + + "${Constants.Tables.SONG_TABLE} as song ON composer.name = song.composer GROUP BY composer.name " + + "HAVING COUNT(song.location) = 0)") + suspend fun cleanComposerTable() } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/daos/GenreDao.kt b/app/src/main/java/com/github/pakka_papad/data/daos/GenreDao.kt index 4d16439..1a43983 100644 --- a/app/src/main/java/com/github/pakka_papad/data/daos/GenreDao.kt +++ b/app/src/main/java/com/github/pakka_papad/data/daos/GenreDao.kt @@ -19,4 +19,13 @@ interface GenreDao { @Query("SELECT * FROM ${Constants.Tables.GENRE_TABLE} WHERE genre = :genreName") fun getGenreWithSongs(genreName: String): Flow + @Query("DELETE FROM ${Constants.Tables.GENRE_TABLE} WHERE genre = :genre") + suspend fun deleteGenre(genre: String) + + @Transaction + @Query("DELETE FROM ${Constants.Tables.GENRE_TABLE} WHERE genre IN " + + "(SELECT genre.genre as genre FROM ${Constants.Tables.GENRE_TABLE} as genre LEFT JOIN " + + "${Constants.Tables.SONG_TABLE} as song ON genre.genre = song.genre GROUP BY genre.genre " + + "HAVING COUNT(song.location) = 0)") + suspend fun cleanGenreTable() } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/daos/LyricistDao.kt b/app/src/main/java/com/github/pakka_papad/data/daos/LyricistDao.kt index 1a7c088..4a95f67 100644 --- a/app/src/main/java/com/github/pakka_papad/data/daos/LyricistDao.kt +++ b/app/src/main/java/com/github/pakka_papad/data/daos/LyricistDao.kt @@ -19,4 +19,10 @@ interface LyricistDao { @Query("SELECT * FROM ${Constants.Tables.LYRICIST_TABLE} WHERE name = :name") fun getLyricistWithSongs(name: String): Flow + @Transaction + @Query("DELETE FROM ${Constants.Tables.LYRICIST_TABLE} WHERE name IN " + + "(SELECT lyricist.name as name FROM ${Constants.Tables.LYRICIST_TABLE} as lyricist LEFT JOIN " + + "${Constants.Tables.SONG_TABLE} as song ON lyricist.name = song.lyricist GROUP BY lyricist.name " + + "HAVING COUNT(song.location) = 0)") + suspend fun cleanLyricistTable() } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/data/daos/SongDao.kt b/app/src/main/java/com/github/pakka_papad/data/daos/SongDao.kt index af8a310..05bb650 100644 --- a/app/src/main/java/com/github/pakka_papad/data/daos/SongDao.kt +++ b/app/src/main/java/com/github/pakka_papad/data/daos/SongDao.kt @@ -26,6 +26,10 @@ interface SongDao { @Query("DELETE FROM ${Constants.Tables.SONG_TABLE}") suspend fun deleteAllSongs() + @Transaction + @Query("DELETE FROM ${Constants.Tables.SONG_TABLE} WHERE location LIKE :prefix || '%'") + suspend fun deleteSongsWithPathPrefix(prefix: String) + @Query("SELECT * FROM ${Constants.Tables.SONG_TABLE} WHERE title LIKE '%' || :query || '%' OR " + "artist LIKE '%' || :query || '%' OR " + "albumArtist LIKE '%' || :query || '%' OR " + diff --git a/app/src/main/java/com/github/pakka_papad/data/music/BlacklistedFolder.kt b/app/src/main/java/com/github/pakka_papad/data/music/BlacklistedFolder.kt new file mode 100644 index 0000000..14641c8 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/data/music/BlacklistedFolder.kt @@ -0,0 +1,10 @@ +package com.github.pakka_papad.data.music + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.github.pakka_papad.Constants + +@Entity(tableName = Constants.Tables.BLACKLISTED_FOLDER_TABLE) +data class BlacklistedFolder( + @PrimaryKey val path: String, +) diff --git a/app/src/main/java/com/github/pakka_papad/data/music/MiniSong.kt b/app/src/main/java/com/github/pakka_papad/data/music/MiniSong.kt new file mode 100644 index 0000000..0895550 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/data/music/MiniSong.kt @@ -0,0 +1,8 @@ +package com.github.pakka_papad.data.music + +data class MiniSong( + val title: String, + val location: String, + val artist: String, + val artUri: String, +) diff --git a/app/src/main/java/com/github/pakka_papad/data/music/Song.kt b/app/src/main/java/com/github/pakka_papad/data/music/Song.kt index 90e5353..0b42531 100644 --- a/app/src/main/java/com/github/pakka_papad/data/music/Song.kt +++ b/app/src/main/java/com/github/pakka_papad/data/music/Song.kt @@ -13,52 +13,52 @@ import com.github.pakka_papad.Constants entity = Album::class, parentColumns = ["name"], childColumns = ["album"], - onDelete = ForeignKey.SET_NULL, + onDelete = ForeignKey.SET_DEFAULT, ), ForeignKey( entity = Artist::class, parentColumns = ["name"], childColumns = ["artist"], - onDelete = ForeignKey.SET_NULL + onDelete = ForeignKey.SET_DEFAULT, ), ForeignKey( entity = Genre::class, parentColumns = ["genre"], childColumns = ["genre"], - onDelete = ForeignKey.SET_NULL, + onDelete = ForeignKey.SET_DEFAULT, ), ForeignKey( entity = AlbumArtist::class, parentColumns = ["name"], childColumns = ["albumArtist"], - onDelete = ForeignKey.SET_NULL + onDelete = ForeignKey.SET_DEFAULT, ), ForeignKey( entity = Lyricist::class, parentColumns = ["name"], childColumns = ["lyricist"], - onDelete = ForeignKey.SET_NULL + onDelete = ForeignKey.SET_DEFAULT, ), ForeignKey( entity = Composer::class, parentColumns = ["name"], childColumns = ["composer"], - onDelete = ForeignKey.SET_NULL + onDelete = ForeignKey.SET_DEFAULT, ), ] ) data class Song( @PrimaryKey val location: String = "", val title: String, - @ColumnInfo(index = true) val album: String = "", + @ColumnInfo(index = true, defaultValue = "Unknown") val album: String = "", val size: String, val addedDate: String, val modifiedDate: String, - @ColumnInfo(index = true) val artist: String, - @ColumnInfo(index = true) val albumArtist: String, - @ColumnInfo(index = true) val composer: String, - @ColumnInfo(index = true) val genre: String, - @ColumnInfo(index = true) val lyricist: String, + @ColumnInfo(index = true, defaultValue = "Unknown") val artist: String, + @ColumnInfo(index = true, defaultValue = "Unknown") val albumArtist: String, + @ColumnInfo(index = true, defaultValue = "Unknown") val composer: String, + @ColumnInfo(index = true, defaultValue = "Unknown") val genre: String, + @ColumnInfo(index = true, defaultValue = "Unknown") val lyricist: String, val year: Int, val comment: String? = null, val durationMillis: Long, @@ -69,6 +69,8 @@ data class Song( val mimeType: String? = null, val favourite: Boolean = false, val artUri: String? = null, + @ColumnInfo(defaultValue = "0") val playCount: Int = 0, + val lastPlayed: Long? = null, ){ data class Metadata( val artist: String, diff --git a/app/src/main/java/com/github/pakka_papad/data/music/SongExtractor.kt b/app/src/main/java/com/github/pakka_papad/data/music/SongExtractor.kt new file mode 100644 index 0000000..6c635f3 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/data/music/SongExtractor.kt @@ -0,0 +1,303 @@ +package com.github.pakka_papad.data.music + +import android.content.ContentUris +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import com.github.pakka_papad.formatToDate +import com.github.pakka_papad.toMBfromB +import com.github.pakka_papad.toMS +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import java.io.File +import java.io.FileNotFoundException +import java.util.TreeMap + +class SongExtractor( + private val scope: CoroutineScope, + private val context: Context, +) { + + private val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.SIZE, + MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.DATE_MODIFIED, + ) + + fun resolveSong(location: String): Song? { + val selection = MediaStore.Audio.Media.DATA + " LIKE ?" + val selectionArgs = arrayOf(location) + val cursor = context.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + MediaStore.Audio.Media.DATE_ADDED, + null + ) ?: return null + val dataIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATA) + val titleIndex = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE) + val albumIndex = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM) + val sizeIndex = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE) + val dateAddedIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_ADDED) + val dateModifiedIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_MODIFIED) + val songIdIndex = cursor.getColumnIndex(MediaStore.Audio.Media._ID) + var resSong: Song? = null + cursor.moveToFirst() + try { + val songPath = cursor.getString(dataIndex) + val songFile = File(songPath) + if (!songFile.exists()) throw FileNotFoundException() + val size = cursor.getString(sizeIndex) + val addedDate = cursor.getString(dateAddedIndex) + val modifiedDate = cursor.getString(dateModifiedIndex) + val songId = cursor.getLong(songIdIndex) + val title = cursor.getString(titleIndex).trim() + val album = cursor.getString(albumIndex).trim() + resSong = getSong( + path = songPath, + size = size, + addedDate = addedDate, + modifiedDate = modifiedDate, + songId = songId, + title = title, + album = album, + ) + } catch (_: Exception){ + + } + cursor.close() + return resSong + } + + suspend fun extract(folderPath: String? = null): List { + val selection = MediaStore.Audio.Media.DATA + " LIKE ?" + val selectionArgs = folderPath?.let { + arrayOf("$it%") + } + val cursor = context.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + MediaStore.Audio.Media.DATE_ADDED, + null + ) ?: return emptyList() + val dataIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATA) + val titleIndex = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE) + val albumIndex = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM) + val sizeIndex = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE) + val dateAddedIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_ADDED) + val dateModifiedIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_MODIFIED) + val songIdIndex = cursor.getColumnIndex(MediaStore.Audio.Media._ID) + val dSongs = ArrayList>() + cursor.moveToFirst() + do { + try { + val songPath = cursor.getString(dataIndex) + val songFile = File(songPath) + if (!songFile.exists()) throw FileNotFoundException() + if (folderPath != null && songFile.parentFile?.absolutePath != folderPath) throw Exception() + val size = cursor.getString(sizeIndex) + val addedDate = cursor.getString(dateAddedIndex) + val modifiedDate = cursor.getString(dateModifiedIndex) + val songId = cursor.getLong(songIdIndex) + val title = cursor.getString(titleIndex).trim() + val album = cursor.getString(albumIndex).trim() + dSongs.add(scope.async { + getSong( + path = songPath, + size = size, + addedDate = addedDate, + modifiedDate = modifiedDate, + songId = songId, + title = title, + album = album, + ) + }) + } catch (_: Exception){ + + } + } while (cursor.moveToNext()) + val songs = dSongs.awaitAll() + cursor.close() + return songs + } + + fun extractMini(folderPath: String? = null): List { + val projectionForMini = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.ARTIST, + ) + val selection = MediaStore.Audio.Media.DATA + " LIKE ?" + val selectionArgs = folderPath?.let { + arrayOf("$it%") + } + val cursor = context.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projectionForMini, + selection, + selectionArgs, + MediaStore.Audio.Media.DATE_ADDED, + null + ) ?: return emptyList() + val dataIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATA) + val titleIndex = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE) + val artistIndex = cursor.getColumnIndex(MediaStore.Audio.Media.ARTIST) + val songIdIndex = cursor.getColumnIndex(MediaStore.Audio.Media._ID) + val songs = ArrayList() + cursor.moveToFirst() + do { + try { + val songPath = cursor.getString(dataIndex) + val songFile = File(songPath) + if (!songFile.exists()) throw FileNotFoundException() + if (folderPath != null && songFile.parentFile?.absolutePath != folderPath) throw Exception() + songs.add( + MiniSong( + location = songPath, + title = cursor.getString(titleIndex).trim(), + artUri = "content://media/external/audio/media/${cursor.getLong(songIdIndex)}/albumart", + artist = cursor.getString(artistIndex) + ) + ) + } catch (_: Exception){ + + } + } while (cursor.moveToNext()) + cursor.close() + return songs + } + + suspend fun extract( + blacklistedSongLocations: HashSet, + blacklistedFolderPaths: HashSet, + statusListener: ((parsed: Int, total: Int) -> Unit)? = null + ): Pair,List> { + val selection = StringBuilder() + val selectionArgs = arrayListOf() + selection.append(MediaStore.Audio.Media.IS_MUSIC + " != 0 ") + blacklistedFolderPaths.forEachIndexed { index, path -> + selection.append(" AND NOT ") + .append(MediaStore.Audio.Media.DATA) + .append(" LIKE ?") + selectionArgs.add("$path%") + } + val cursor = context.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selection.toString(), + selectionArgs.toTypedArray(), + MediaStore.Audio.Media.DATE_ADDED, + null + ) ?: return Pair(emptyList(), emptyList()) + val songCover = Uri.parse("content://media/external/audio/albumart") + val albumArtMap = TreeMap() + val dataIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATA) + val titleIndex = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE) + val albumIdIndex = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID) + val albumIndex = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM) + val sizeIndex = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE) + val dateAddedIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_ADDED) + val dateModifiedIndex = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_MODIFIED) + val songIdIndex = cursor.getColumnIndex(MediaStore.Audio.Media._ID) + val dSongs = ArrayList>() + val total = cursor.count + var parsed = 0 + cursor.moveToFirst() + do { + try { + val songPath = cursor.getString(dataIndex) + val songFile = File(songPath) + if (!songFile.exists()) throw FileNotFoundException() + if (blacklistedSongLocations.contains(songFile.path)) continue + val size = cursor.getString(sizeIndex) + val addedDate = cursor.getString(dateAddedIndex) + val modifiedDate = cursor.getString(dateModifiedIndex) + val songId = cursor.getLong(songIdIndex) + val title = cursor.getString(titleIndex).trim() + val album = cursor.getString(albumIndex).trim() + albumArtMap[album] = cursor.getLong(albumIdIndex) + dSongs.add(scope.async { + getSong( + path = songPath, + size = size, + addedDate = addedDate, + modifiedDate = modifiedDate, + songId = songId, + title = title, + album = album, + ) + }) + parsed++ + statusListener?.invoke(parsed, total) + } catch (_: Exception){ + + } + } while (cursor.moveToNext()) + val songs = dSongs.awaitAll() + cursor.close() + val albums = albumArtMap.map { (t, u) -> Album(t, ContentUris.withAppendedId(songCover, u).toString()) } + return Pair(songs,albums) + } + + companion object { + private const val UNKNOWN = "Unknown" + } + + private fun getSong( + path: String, + size: String, + addedDate: String, + modifiedDate: String, + songId: Long, + title: String, + album: String, + ): Song { + val extractor = MediaMetadataRetriever() + extractor.setDataSource(path) + val durationMillis = extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0 + val sampleRate = if (Build.VERSION.SDK_INT >= 31){ + extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_SAMPLERATE)?.toFloatOrNull() ?: 0f + } else 0f + val bitsPerSample = if (Build.VERSION.SDK_INT >= 31){ + extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITS_PER_SAMPLE)?.toIntOrNull() ?: 0 + } else 0 + val song = Song( + location = path, + title = title, + album = album, + size = size.toFloat().toMBfromB(), + addedDate = addedDate.toLong().formatToDate(), + modifiedDate = modifiedDate.toLong().formatToDate(), + artist = extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)?.trim() ?: UNKNOWN, + albumArtist = extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST)?.trim() ?: UNKNOWN, + composer = extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COMPOSER)?.trim() ?: UNKNOWN, + genre = extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_GENRE)?.trim() ?: UNKNOWN, + lyricist = extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_WRITER)?.trim() ?: UNKNOWN, + year = extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_YEAR)?.toIntOrNull() ?: 0, + comment = null, + durationMillis = durationMillis, + durationFormatted = durationMillis.toMS(), + bitrate = extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toFloatOrNull() ?: 0f, + sampleRate = sampleRate, + bitsPerSample = bitsPerSample, + mimeType = extractor.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE), + favourite = false, + artUri = "content://media/external/audio/media/$songId/albumart" + ) + extractor.release() + return song + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/di/AppModule.kt b/app/src/main/java/com/github/pakka_papad/di/AppModule.kt index cb07faa..3c227f0 100644 --- a/app/src/main/java/com/github/pakka_papad/di/AppModule.kt +++ b/app/src/main/java/com/github/pakka_papad/di/AppModule.kt @@ -1,5 +1,6 @@ package com.github.pakka_papad.di +import android.annotation.SuppressLint import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory @@ -20,6 +21,7 @@ import dagger.hilt.components.SingletonComponent import com.github.pakka_papad.Constants import com.github.pakka_papad.data.* import com.github.pakka_papad.data.components.DaoCollection +import com.github.pakka_papad.data.music.SongExtractor import com.github.pakka_papad.data.notification.ZenNotificationManager import com.google.firebase.crashlytics.FirebaseCrashlytics import kotlinx.coroutines.CoroutineScope @@ -49,7 +51,8 @@ object AppModule { @ApplicationContext context: Context, notificationManager: ZenNotificationManager, db: AppDatabase, - scope: CoroutineScope + scope: CoroutineScope, + extractor: SongExtractor, ): DataManager { return DataManager( context = context, @@ -64,8 +67,11 @@ object AppModule { genreDao = db.genreDao(), playlistDao = db.playlistDao(), blacklistDao = db.blacklistDao(), + blacklistedFolderDao = db.blacklistedFolderDao(), + playHistoryDao = db.playHistoryDao() ), scope = scope, + songExtractor = extractor ) } @@ -75,6 +81,7 @@ object AppModule { return ZenNotificationManager(context) } + @SuppressLint("UnsafeOptInUsageError") @Singleton @Provides fun providesExoPlayer( @@ -135,4 +142,16 @@ object AppModule { firebase = FirebaseCrashlytics.getInstance() ) } + + @Singleton + @Provides + fun providesSongExtractor( + @ApplicationContext context: Context, + ): SongExtractor { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + return SongExtractor( + scope = scope, + context = context, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/home/AllSongs.kt b/app/src/main/java/com/github/pakka_papad/home/AllSongs.kt index cbd2b95..d6c34df 100644 --- a/app/src/main/java/com/github/pakka_papad/home/AllSongs.kt +++ b/app/src/main/java/com/github/pakka_papad/home/AllSongs.kt @@ -23,6 +23,7 @@ import com.github.pakka_papad.components.FullScreenSadMessage import com.github.pakka_papad.components.SongCardV1 import com.github.pakka_papad.components.more_options.SongOptions import com.github.pakka_papad.data.music.Song +import com.github.pakka_papad.formatToDate @Composable fun AllSongs( @@ -142,6 +143,10 @@ fun SongInfo( withStyle(spanStyle) { append(if (song.year == 0) "Unknown" else song.year.toString()) } append("\n\nDuration\n") withStyle(spanStyle) { append(if (song.durationMillis == 0L) "Unknown" else song.durationFormatted) } + append("\n\nPlay count\n") + withStyle(spanStyle) { append(song.playCount.toString()) } + append("\n\nLast played on\n") + withStyle(spanStyle) { append(if (song.lastPlayed == null) "Never" else song.lastPlayed.formatToDate()) } append("\n\nMime type\n") withStyle(spanStyle) { append(song.mimeType ?: "Unknown") } }, diff --git a/app/src/main/java/com/github/pakka_papad/home/Files.kt b/app/src/main/java/com/github/pakka_papad/home/Files.kt new file mode 100644 index 0000000..4695fdf --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/home/Files.kt @@ -0,0 +1,150 @@ +package com.github.pakka_papad.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.github.pakka_papad.components.FullScreenSadMessage +import com.github.pakka_papad.R +import com.github.pakka_papad.components.MiniSongCard +import com.github.pakka_papad.components.more_options.FolderOptions +import com.github.pakka_papad.components.more_options.OptionsAlertDialog +import com.github.pakka_papad.components.more_options.SongOptions +import com.github.pakka_papad.data.music.MiniSong +import com.github.pakka_papad.data.music.Song +import com.github.pakka_papad.storage_explorer.Directory +import com.github.pakka_papad.storage_explorer.DirectoryContents + +@Composable +fun Files( + contents: DirectoryContents, + onDirectoryClicked: (Directory) -> Unit, + onSongClicked: (index: Int) -> Unit, + currentSong: Song?, + onAddToPlaylistClicked: (MiniSong) -> Unit, + onAddToQueueClicked: (MiniSong) -> Unit, + onFolderAddToBlacklistRequest: (Directory) -> Unit, +){ + if (contents.directories.isEmpty() && contents.songs.isEmpty()){ + FullScreenSadMessage("Nothing here") + return + } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(), + ){ + items( + items = contents.directories, + key = { it.absolutePath } + ){ + Folder( + folder = it, + onDirectoryClicked = onDirectoryClicked, + options = listOf( + FolderOptions.Blacklist { onFolderAddToBlacklistRequest(it) } + ) + ) + } + itemsIndexed( + items = contents.songs, + key = { index, song -> song.location } + ){index, song -> + MiniSongCard( + song = song, + onSongClicked = { onSongClicked(index) }, + songOptions = listOf( + SongOptions.AddToPlaylist{ onAddToPlaylistClicked(song) }, + SongOptions.AddToQueue{ onAddToQueueClicked(song) }, + ), + currentlyPlaying = (song.location == currentSong?.location) + ) + } + } +} + +@Composable +fun Folder( + folder: Directory, + onDirectoryClicked: (Directory) -> Unit, + options: List, +){ + val resource = painterResource(R.drawable.ic_baseline_folder_40) + var showClickIndicator by remember { mutableStateOf(false) } + var showFileMenu by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onDirectoryClicked(folder) + showClickIndicator = true + } + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = resource, + contentDescription = null, + modifier = Modifier.size(50.dp) + ) + Text( + text = folder.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if(showClickIndicator){ + CircularProgressIndicator( + modifier = Modifier.size(26.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = null, + modifier = Modifier + .size(26.dp) + .clickable( + onClick = { + showFileMenu = true + }, + indication = rememberRipple( + bounded = false, + radius = 20.dp + ), + interactionSource = remember { MutableInteractionSource() } + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + if (showFileMenu){ + OptionsAlertDialog( + options = options, + title = folder.name, + onDismissRequest = { + showFileMenu = false + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/home/HomeBottomBar.kt b/app/src/main/java/com/github/pakka_papad/home/HomeBottomBar.kt index 79a22ac..f485d1b 100644 --- a/app/src/main/java/com/github/pakka_papad/home/HomeBottomBar.kt +++ b/app/src/main/java/com/github/pakka_papad/home/HomeBottomBar.kt @@ -17,14 +17,18 @@ fun HomeBottomBar( currentScreen: Screens, onScreenChange: (Screens) -> Unit, bottomBarColor: Color, + selectedTabs: List? ) { + if (selectedTabs == null) return + val screens = Screens.values() BottomAppBar( modifier = Modifier .background(bottomBarColor) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) .height(88.dp), ) { - Screens.values().forEach { screen -> + selectedTabs.filter { it >= 0 && it < screens.size } + .map { screens[it] }.forEach { screen -> NavigationBarItem( selected = (currentScreen == screen), onClick = { diff --git a/app/src/main/java/com/github/pakka_papad/home/HomeFragment.kt b/app/src/main/java/com/github/pakka_papad/home/HomeFragment.kt index c5dfbd6..4cfadbd 100644 --- a/app/src/main/java/com/github/pakka_papad/home/HomeFragment.kt +++ b/app/src/main/java/com/github/pakka_papad/home/HomeFragment.kt @@ -14,9 +14,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.BottomSheetScaffold +import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material.rememberSwipeableState import androidx.compose.material3.* import androidx.compose.runtime.* @@ -108,9 +108,12 @@ class HomeFragment : Fragment() { val systemUiController = rememberSystemUiController() val themePreference by preferenceProvider.theme.collectAsStateWithLifecycle() ZenTheme(themePreference, systemUiController) { + val selectedTabs by preferenceProvider.selectedTabs.collectAsStateWithLifecycle() var currentScreen by rememberSaveable { mutableStateOf(Screens.Songs) } val scope = rememberCoroutineScope() + val sortOrder by preferenceProvider.sortOrder.collectAsStateWithLifecycle() + val songs by viewModel.songs.collectAsStateWithLifecycle() val allSongsListState = rememberLazyListState() @@ -130,6 +133,8 @@ class HomeFragment : Fragment() { val genresWithSongCount by viewModel.genresWithSongCount.collectAsStateWithLifecycle() val allGenresListState = rememberLazyListState() + val files by viewModel.filesInCurrentDestination.collectAsStateWithLifecycle() + val dataRetrieved by remember { derivedStateOf { songs != null && albums != null && personsWithSongCount != null @@ -169,12 +174,25 @@ class HomeFragment : Fragment() { val repeatMode by viewModel.repeatMode.collectAsStateWithLifecycle() val playerScaffoldState = rememberBottomSheetScaffoldState() + val isExplorerAtRoot by viewModel.isExplorerAtRoot.collectAsStateWithLifecycle() + + val isQueueBottomSheetExpanded by remember(playerScaffoldState.bottomSheetState) { + derivedStateOf { + playerScaffoldState.bottomSheetState.currentValue + .equals(SheetValue.Expanded) + } + } + BackHandler( - enabled = swipeableState.currentValue == 1, + enabled = (currentScreen == Screens.Folders && !isExplorerAtRoot) || swipeableState.currentValue == 1, onBack = { - scope.launch { - if (playerScaffoldState.bottomSheetState.isExpanded) playerScaffoldState.bottomSheetState.collapse() - else swipeableState.animateTo(0) + if (swipeableState.currentValue == 1){ + scope.launch { + if (isQueueBottomSheetExpanded) playerScaffoldState.bottomSheetState.hide() + else swipeableState.animateTo(0) + } + } else { + viewModel.moveToParent() } } ) @@ -205,9 +223,6 @@ class HomeFragment : Fragment() { val expandQueueBottomSheet = remember<() -> Unit>{ { scope.launch { playerScaffoldState.bottomSheetState.expand() } } } - val collapseQueueBottomSheet = remember<() -> Unit>{ - { scope.launch { playerScaffoldState.bottomSheetState.collapse() } } - } val updateScreen = remember<(Screens) -> Unit>{ { if (currentScreen == it){ scope.launch { @@ -217,6 +232,7 @@ class HomeFragment : Fragment() { Screens.Artists -> allPersonsListState.scrollToItem(0) Screens.Playlists -> allPlaylistsListState.scrollToItem(0) Screens.Genres -> allGenresListState.scrollToItem(0) + else -> {} } } } else { @@ -234,6 +250,9 @@ class HomeFragment : Fragment() { HomeTopBar( onSettingsClicked = navigateToSettings, onSearchClicked = navigateToSearch, + currentScreen = currentScreen, + onSortOptionChosen = viewModel::saveSortOption, + currentSortOrder = sortOrder, ) }, content = { @@ -242,7 +261,9 @@ class HomeFragment : Fragment() { .padding( top = it.calculateTopPadding(), bottom = if (currentSong == null) 88.dp else 146.dp, - start = windowInsets.calculateStartPadding(LayoutDirection.Ltr), + start = windowInsets.calculateStartPadding( + LayoutDirection.Ltr + ), end = windowInsets.calculateEndPadding(LayoutDirection.Ltr) ) .fillMaxSize(), @@ -253,7 +274,10 @@ class HomeFragment : Fragment() { modifier = Modifier.align(Alignment.Center) ) } else { - AnimatedContent(targetState = currentScreen) { targetScreen -> + AnimatedContent( + targetState = currentScreen, + label = "" + ) { targetScreen -> when (targetScreen) { Screens.Songs -> { AllSongs( @@ -302,6 +326,17 @@ class HomeFragment : Fragment() { onGenreClicked = this@HomeFragment::navigateToCollection ) } + Screens.Folders -> { + Files( + contents = files, + onDirectoryClicked = viewModel::onFileClicked, + onSongClicked = viewModel::onFileClicked, + currentSong = currentSong, + onAddToPlaylistClicked = this@HomeFragment::addToPlaylistClicked, + onAddToQueueClicked = viewModel::addToQueue, + onFolderAddToBlacklistRequest = viewModel::onFolderBlacklist + ) + } } } } @@ -316,7 +351,9 @@ class HomeFragment : Fragment() { .fillMaxWidth() .background(bottomBarColor) .padding( - start = windowInsets.calculateStartPadding(LayoutDirection.Ltr), + start = windowInsets.calculateStartPadding( + LayoutDirection.Ltr + ), end = windowInsets.calculateEndPadding(LayoutDirection.Ltr), ) ) { @@ -397,8 +434,7 @@ class HomeFragment : Fragment() { queue = queue, onFavouriteClicked = viewModel::changeFavouriteValue, currentSong = it, - onDownArrowClicked = collapseQueueBottomSheet, - expanded = playerScaffoldState.bottomSheetState.isExpanded, + expanded = isQueueBottomSheetExpanded, exoPlayer = exoPlayer, onDrag = viewModel::onSongDrag ) @@ -409,9 +445,8 @@ class HomeFragment : Fragment() { bottomStart = 0.dp, bottomEnd = 0.dp ), - sheetElevation = 20.dp, sheetPeekHeight = 0.dp, - sheetGesturesEnabled = true, + sheetContainerColor = MaterialTheme.colorScheme.secondaryContainer, ) } }, @@ -426,7 +461,8 @@ class HomeFragment : Fragment() { HomeBottomBar( currentScreen = currentScreen, onScreenChange = updateScreen, - bottomBarColor = bottomBarColor + bottomBarColor = bottomBarColor, + selectedTabs = selectedTabs, ) } } @@ -519,4 +555,14 @@ class HomeFragment : Fragment() { private fun addToPlaylistClicked(song: Song){ saveToPlaylistClicked(listOf(song)) } + + private fun addToPlaylistClicked(song: MiniSong){ + lifecycleScope.launch { + if (navController.currentDestination?.id != R.id.homeFragment) return@launch + navController.navigate( + HomeFragmentDirections + .actionHomeFragmentToSelectPlaylistFragment(arrayOf(song.location)) + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/home/HomeTopBar.kt b/app/src/main/java/com/github/pakka_papad/home/HomeTopBar.kt index d62368c..f6ea12c 100644 --- a/app/src/main/java/com/github/pakka_papad/home/HomeTopBar.kt +++ b/app/src/main/java/com/github/pakka_papad/home/HomeTopBar.kt @@ -12,9 +12,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.surfaceColorAtElevation 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 +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -22,68 +27,108 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.pakka_papad.components.SmallTopBar +import com.github.pakka_papad.R +import com.github.pakka_papad.Screens +import com.github.pakka_papad.components.SortOptionChooser +import com.github.pakka_papad.components.getSortOptions @Composable fun HomeTopBar( onSettingsClicked: () -> Unit, onSearchClicked: () -> Unit, -) = SmallTopBar( - leadingIcon = { }, - title = buildAnnotatedString { - withStyle( - SpanStyle( - fontWeight = FontWeight.ExtraBold, - fontSize = 26.sp, - ) - ) { - append("Zen ") - } - withStyle( - SpanStyle( - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.ExtraBold, - fontSize = 26.sp + currentScreen: Screens, + onSortOptionChosen: (currScreen: Int, option: Int) -> Unit, + currentSortOrder: Map, +) { + var sortMenuVisible by remember { mutableStateOf(false) } + val options by remember(currentScreen.ordinal) { derivedStateOf { + currentScreen.getSortOptions() + } } + SmallTopBar( + leadingIcon = { }, + title = buildAnnotatedString { + withStyle( + SpanStyle( + fontWeight = FontWeight.ExtraBold, + fontSize = 26.sp, + ) + ) { + append("Zen ") + } + withStyle( + SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.ExtraBold, + fontSize = 26.sp + ) + ) { + append("Music") + } + }, + actions = { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = "search-btn", + modifier = Modifier + .size(48.dp) + .padding(9.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple( + bounded = false, + radius = 25.dp, + ), + onClick = onSearchClicked + ), + tint = MaterialTheme.colorScheme.onSurface, ) - ) { - append("Music") - } - }, - actions = { - Icon( - imageVector = Icons.Outlined.Search, - contentDescription = "search-btn", - modifier = Modifier - .size(48.dp) - .padding(9.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple( - bounded = false, - radius = 25.dp, + Icon( + painter = painterResource(id = R.drawable.ic_baseline_sort_40), + contentDescription = "sort-btn", + modifier = Modifier + .size(48.dp) + .padding(9.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple( + bounded = false, + radius = 25.dp, + ), + onClick = { sortMenuVisible = true } ), - onClick = onSearchClicked - ), - tint = MaterialTheme.colorScheme.onSurface, - ) - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = "settings-btn", - modifier = Modifier - .size(48.dp) - .padding(9.dp) - .rotate(90f) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple( - bounded = false, - radius = 25.dp, + tint = MaterialTheme.colorScheme.onSurface, + ) + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = "settings-btn", + modifier = Modifier + .size(48.dp) + .padding(9.dp) + .rotate(90f) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple( + bounded = false, + radius = 25.dp, + ), + onClick = onSettingsClicked ), - onClick = onSettingsClicked - ), - tint = MaterialTheme.colorScheme.onSurface, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + titleMaxLines = 1, + backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + onBackgroundColor = MaterialTheme.colorScheme.onSurface, + ) + if (sortMenuVisible){ + SortOptionChooser( + options = options, + selectedOption = currentSortOrder[currentScreen.ordinal] ?: options.first().ordinal, + onOptionSelect = { option -> + onSortOptionChosen(currentScreen.ordinal, option) + sortMenuVisible = false + }, + onChooserDismiss = { sortMenuVisible = false } ) - }, - titleMaxLines = 1, - backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - onBackgroundColor = MaterialTheme.colorScheme.onSurface, -) + } +} diff --git a/app/src/main/java/com/github/pakka_papad/home/HomeViewModel.kt b/app/src/main/java/com/github/pakka_papad/home/HomeViewModel.kt index b5d6246..18cf0e5 100644 --- a/app/src/main/java/com/github/pakka_papad/home/HomeViewModel.kt +++ b/app/src/main/java/com/github/pakka_papad/home/HomeViewModel.kt @@ -6,8 +6,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer +import com.github.pakka_papad.components.SortOptions import com.github.pakka_papad.data.DataManager +import com.github.pakka_papad.data.ZenPreferenceProvider import com.github.pakka_papad.data.music.* +import com.github.pakka_papad.storage_explorer.* +import com.github.pakka_papad.storage_explorer.Directory import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -19,10 +23,26 @@ class HomeViewModel @Inject constructor( private val context: Application, private val manager: DataManager, private val exoPlayer: ExoPlayer, + private val songExtractor: SongExtractor, + private val prefs: ZenPreferenceProvider, ) : ViewModel() { val songs = manager.getAll.songs() - .catch { exception -> + .combine(prefs.songSortOrder){ songs, sortOrder -> + when(sortOrder){ + SortOptions.TitleASC.ordinal -> songs.sortedBy { it.title } + SortOptions.TitleDSC.ordinal -> songs.sortedByDescending { it.title } + SortOptions.AlbumASC.ordinal -> songs.sortedBy { it.album } + SortOptions.AlbumDSC.ordinal -> songs.sortedByDescending { it.album } + SortOptions.ArtistASC.ordinal -> songs.sortedBy { it.artist } + SortOptions.ArtistDSC.ordinal -> songs.sortedByDescending { it.artist } + SortOptions.YearASC.ordinal -> songs.sortedBy { it.year } + SortOptions.YearDSC.ordinal -> songs.sortedByDescending { it.year } + SortOptions.DurationASC.ordinal -> songs.sortedBy { it.durationMillis } + SortOptions.DurationDSC.ordinal -> songs.sortedByDescending { it.durationMillis } + else -> songs + } + }.catch { exception -> Timber.e(exception) }.stateIn( scope = viewModelScope, @@ -31,7 +51,13 @@ class HomeViewModel @Inject constructor( ) val albums = manager.getAll.albums() - .catch { exception -> + .combine(prefs.albumSortOrder){ albums, sortOrder -> + when(sortOrder){ + SortOptions.TitleASC.ordinal -> albums.sortedBy { it.name } + SortOptions.TitleDSC.ordinal -> albums.sortedByDescending { it.name } + else -> albums + } + }.catch { exception -> Timber.e(exception) }.stateIn( scope = viewModelScope, @@ -55,6 +81,14 @@ class HomeViewModel @Inject constructor( Person.Composer -> manager.getAll.composers() Person.Lyricist -> manager.getAll.lyricists() } + }.combine(prefs.artistSortOrder){ artists, sortOrder -> + when(sortOrder){ + SortOptions.NameASC.ordinal -> artists.sortedBy { it.name } + SortOptions.NameDSC.ordinal -> artists.sortedByDescending { it.name } + SortOptions.SongsCountASC.ordinal -> artists.sortedBy { it.count } + SortOptions.SongsCountDSC.ordinal -> artists.sortedByDescending { it.count } + else -> artists + } }.catch { exception -> Timber.e(exception) }.stateIn( @@ -64,7 +98,15 @@ class HomeViewModel @Inject constructor( ) val playlistsWithSongCount = manager.getAll.playlists() - .catch { exception -> + .combine(prefs.playlistSortOrder){ playlists, sortOrder -> + when(sortOrder){ + SortOptions.NameASC.ordinal -> playlists.sortedBy { it.playlistName } + SortOptions.NameDSC.ordinal -> playlists.sortedByDescending { it.playlistName } + SortOptions.SongsCountASC.ordinal -> playlists.sortedBy { it.count } + SortOptions.SongsCountDSC.ordinal -> playlists.sortedByDescending { it.count } + else -> playlists + } + }.catch { exception -> Timber.e(exception) }.stateIn( scope = viewModelScope, @@ -73,7 +115,15 @@ class HomeViewModel @Inject constructor( ) val genresWithSongCount = manager.getAll.genres() - .catch { exception -> + .combine(prefs.genreSortOrder){ genres, sortOrder -> + when(sortOrder){ + SortOptions.NameASC.ordinal -> genres.sortedBy { it.genreName } + SortOptions.NameDSC.ordinal -> genres.sortedByDescending { it.genreName } + SortOptions.SongsCountASC.ordinal -> genres.sortedBy { it.count } + SortOptions.SongsCountDSC.ordinal -> genres.sortedByDescending { it.count } + else -> genres + } + }.catch { exception -> Timber.e(exception) }.stateIn( scope = viewModelScope, @@ -81,6 +131,10 @@ class HomeViewModel @Inject constructor( initialValue = emptyList() ) + fun saveSortOption(screen: Int, option: Int){ + prefs.updateSortOrder(screen, option) + } + val currentSong = manager.currentSong val queue = manager.queue @@ -113,6 +167,7 @@ class HomeViewModel @Inject constructor( override fun onCleared() { super.onCleared() exoPlayer.removeListener(exoPlayerListener) + explorer.removeListener(directoryChangeListener) } /** @@ -132,6 +187,17 @@ class HomeViewModel @Inject constructor( } } + fun onFolderBlacklist(folder: Directory){ + viewModelScope.launch { + try { + manager.addFolderToBlacklist(folder.absolutePath) + showToast("Done") + } catch (_: Exception){ + showToast("Some error occurred") + } + } + } + fun onPlaylistCreate(playlistName: String) { viewModelScope.launch { manager.createPlaylist(playlistName) @@ -188,6 +254,11 @@ class HomeViewModel @Inject constructor( } } + fun addToQueue(song: MiniSong) { + val resolvedSong = songExtractor.resolveSong(song.location) ?: return + addToQueue(resolvedSong) + } + /** * Create and set a new queue in exoplayer. * Old queue is discarded. @@ -214,4 +285,69 @@ class HomeViewModel @Inject constructor( fun onSongDrag(fromIndex: Int, toIndex: Int) = manager.moveItem(fromIndex,toIndex) + + private val _filesInCurrentDestination = MutableStateFlow(DirectoryContents()) + val filesInCurrentDestination = _filesInCurrentDestination + .combine(prefs.folderSortOrder){ files, sortOrder -> + when(sortOrder){ + SortOptions.NameASC.ordinal -> { + DirectoryContents( + directories = files.directories.sortedBy { it.name }, + songs = files.songs.sortedBy { it.title } + ) + } + SortOptions.NameDSC.ordinal -> { + DirectoryContents( + directories = files.directories.sortedByDescending { it.name }, + songs = files.songs.sortedByDescending { it.title } + ) + } + else -> files + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = DirectoryContents() + ) + + private val explorer = MusicFileExplorer(songExtractor) + + private val _isExplorerAtRoot = MutableStateFlow(true) + val isExplorerAtRoot = _isExplorerAtRoot.asStateFlow() + + private val directoryChangeListener = object : MusicFileExplorer.DirectoryChangeListener { + override fun onDirectoryChanged(path: String, files: DirectoryContents) { + _filesInCurrentDestination.update { files } + } + } + + init { + viewModelScope.launch { + explorer.addListener(directoryChangeListener) + } + } + + fun onFileClicked(songIndex: Int){ + viewModelScope.launch { + if(songIndex < 0 || songIndex >= filesInCurrentDestination.value.songs.size) return@launch + val song = songExtractor.resolveSong(filesInCurrentDestination.value.songs[songIndex].location) + song?.let { + setQueue(listOf(song)) + } + } + } + + fun onFileClicked(file: Directory){ + viewModelScope.launch(Dispatchers.Default) { + explorer.moveInsideDirectory(file.absolutePath) + _isExplorerAtRoot.update { explorer.isRoot } + } + } + + fun moveToParent() { + viewModelScope.launch(Dispatchers.Default) { + explorer.moveToParent() + _isExplorerAtRoot.update { explorer.isRoot } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/nowplaying/Queue.kt b/app/src/main/java/com/github/pakka_papad/nowplaying/Queue.kt index 06772e3..3e78a05 100644 --- a/app/src/main/java/com/github/pakka_papad/nowplaying/Queue.kt +++ b/app/src/main/java/com/github/pakka_papad/nowplaying/Queue.kt @@ -2,22 +2,15 @@ package com.github.pakka_papad.nowplaying import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.KeyboardArrowDown -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.media3.exoplayer.ExoPlayer import com.github.pakka_papad.components.SongCardV2 import com.github.pakka_papad.data.music.Song @@ -30,28 +23,10 @@ fun ColumnScope.Queue( queue: List, onFavouriteClicked: (Song) -> Unit, currentSong: Song?, - onDownArrowClicked: () -> Unit, expanded: Boolean, exoPlayer: ExoPlayer, onDrag: (fromIndex: Int, toIndex: Int) -> Unit, ) { - Icon( - imageVector = Icons.Outlined.KeyboardArrowDown, - contentDescription = "down arrow icon", - modifier = Modifier - .background(MaterialTheme.colorScheme.secondaryContainer) - .fillMaxWidth() - .size(36.dp) - .clickable( - onClick = onDownArrowClicked, - indication = rememberRipple( - bounded = true, - radius = 18.dp - ), - interactionSource = remember { MutableInteractionSource() } - ), - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) val listState = rememberLazyListState() LaunchedEffect(key1 = currentSong, key2 = expanded) { delay(600) @@ -77,7 +52,7 @@ fun ColumnScope.Queue( .dragContainer(dragDropState), state = listState, contentPadding = WindowInsets.systemBars - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + .only(WindowInsetsSides.Bottom) .asPaddingValues(), ) { itemsIndexed( diff --git a/app/src/main/java/com/github/pakka_papad/player/ZenPlayer.kt b/app/src/main/java/com/github/pakka_papad/player/ZenPlayer.kt index 85f66f4..fc5908f 100644 --- a/app/src/main/java/com/github/pakka_papad/player/ZenPlayer.kt +++ b/app/src/main/java/com/github/pakka_papad/player/ZenPlayer.kt @@ -2,6 +2,7 @@ package com.github.pakka_papad.player import android.app.NotificationManager import android.app.Service +import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -15,20 +16,25 @@ import android.widget.Toast import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.PlaybackStatsListener import com.github.pakka_papad.* import com.github.pakka_papad.data.DataManager import com.github.pakka_papad.data.ZenCrashReporter import com.github.pakka_papad.data.ZenPreferenceProvider import com.github.pakka_papad.data.music.Song import com.github.pakka_papad.data.notification.ZenNotificationManager +import com.github.pakka_papad.widgets.WidgetBroadcast import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import timber.log.Timber import java.io.File import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds -@AndroidEntryPoint +@UnstableApi @AndroidEntryPoint class ZenPlayer : Service(), DataManager.Callback, ZenBroadcastReceiver.Callback { @Inject @@ -53,6 +59,20 @@ class ZenPlayer : Service(), DataManager.Callback, ZenBroadcastReceiver.Callback private val job = SupervisorJob() private val scope = CoroutineScope(job + Dispatchers.Default) + private val playTimeThresholdMs = 10.seconds.inWholeMilliseconds + + private val playbackStatsListener = PlaybackStatsListener(false) { eventTime, playbackStats -> + if (playbackStats.totalPlayTimeMs < playTimeThresholdMs) return@PlaybackStatsListener + val window = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()) + try { + window.mediaItem.localConfiguration?.tag?.let { + dataManager.addPlayHistory(it as String, playbackStats.totalPlayTimeMs) + } + } catch (_ : Exception){ + + } + } + companion object { const val MEDIA_SESSION = "media_session" } @@ -67,6 +87,16 @@ class ZenPlayer : Service(), DataManager.Callback, ZenBroadcastReceiver.Callback try { dataManager.updateCurrentSong(exoPlayer.currentMediaItemIndex) + dataManager.getSongAtIndex(exoPlayer.currentMediaItemIndex)?.let { song -> + val broadcast = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).apply { + putExtra(WidgetBroadcast.WIDGET_BROADCAST, WidgetBroadcast.SONG_CHANGED) + putExtra("imageUri", song.artUri) + putExtra("title", song.title) + putExtra("artist", song.artist) + putExtra("album", song.album) + } + this@ZenPlayer.applicationContext.sendBroadcast(broadcast) + } } catch (e: Exception) { Timber.e(e) } @@ -76,6 +106,11 @@ class ZenPlayer : Service(), DataManager.Callback, ZenBroadcastReceiver.Callback override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) + val broadcast = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).apply { + putExtra(WidgetBroadcast.WIDGET_BROADCAST, WidgetBroadcast.IS_PLAYING_CHANGED) + putExtra("isPlaying", isPlaying) + } + this@ZenPlayer.applicationContext.sendBroadcast(broadcast) updateMediaSessionState() updateMediaSessionMetadata() } @@ -136,6 +171,7 @@ class ZenPlayer : Service(), DataManager.Callback, ZenBroadcastReceiver.Callback broadcastReceiver?.startListening(this) mediaSession.setCallback(mediaSessionCallback) exoPlayer.addListener(exoPlayerListener) + exoPlayer.addAnalyticsListener(playbackStatsListener) startForeground( ZenNotificationManager.PLAYER_NOTIFICATION_ID, @@ -181,6 +217,7 @@ class ZenPlayer : Service(), DataManager.Callback, ZenBroadcastReceiver.Callback exoPlayer.stop() exoPlayer.clearMediaItems() exoPlayer.removeListener(exoPlayerListener) + exoPlayer.removeAnalyticsListener(playbackStatsListener) mediaSession.release() dataManager.stopPlayerRunning() broadcastReceiver?.stopListening() @@ -189,6 +226,14 @@ class ZenPlayer : Service(), DataManager.Callback, ZenBroadcastReceiver.Callback job.cancel() systemNotificationManager = null broadcastReceiver = null + val broadcast = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).apply { + putExtra(WidgetBroadcast.WIDGET_BROADCAST, WidgetBroadcast.SONG_CHANGED) + putExtra("imageUri", "") + putExtra("title", "") + putExtra("artist", "") + putExtra("album", "") + } + applicationContext.sendBroadcast(broadcast) } private fun updateMediaSessionMetadata() { @@ -258,26 +303,38 @@ class ZenPlayer : Service(), DataManager.Callback, ZenBroadcastReceiver.Callback @Synchronized override fun setQueue(newQueue: List, startPlayingFromIndex: Int) { - exoPlayer.stop() - exoPlayer.clearMediaItems() - val mediaItems = newQueue.map { - MediaItem.fromUri(Uri.fromFile(File(it.location))) + scope.launch { + val mediaItems = newQueue.map { + MediaItem.Builder().apply { + setUri(Uri.fromFile(File(it.location))) + setTag(it.location) + }.build() + } + withContext(Dispatchers.Main){ + exoPlayer.stop() + exoPlayer.clearMediaItems() + exoPlayer.addMediaItems(mediaItems) + exoPlayer.prepare() + exoPlayer.seekTo(startPlayingFromIndex,0) + exoPlayer.repeatMode = dataManager.repeatMode.value.toExoPlayerRepeatMode() + exoPlayer.playbackParameters = preferencesProvider.playbackParams.value + .toCorrectedParams() + .toExoPlayerPlaybackParameters() + exoPlayer.play() + } + updateMediaSessionState() + updateMediaSessionMetadata() } - exoPlayer.addMediaItems(mediaItems) - exoPlayer.prepare() - exoPlayer.seekTo(startPlayingFromIndex,0) - exoPlayer.repeatMode = dataManager.repeatMode.value.toExoPlayerRepeatMode() - exoPlayer.playbackParameters = preferencesProvider.playbackParams.value - .toCorrectedParams() - .toExoPlayerPlaybackParameters() - exoPlayer.play() - updateMediaSessionState() - updateMediaSessionMetadata() } @Synchronized override fun addToQueue(song: Song) { - exoPlayer.addMediaItem(MediaItem.fromUri(Uri.fromFile(File(song.location)))) + exoPlayer.addMediaItem( + MediaItem.Builder().apply { + setUri(Uri.fromFile(File(song.location))) + setTag(song.location) + }.build() + ) } @Synchronized diff --git a/app/src/main/java/com/github/pakka_papad/restore/RestoreContent.kt b/app/src/main/java/com/github/pakka_papad/restore/RestoreContent.kt index 4d5c9de..a1ef718 100644 --- a/app/src/main/java/com/github/pakka_papad/restore/RestoreContent.kt +++ b/app/src/main/java/com/github/pakka_papad/restore/RestoreContent.kt @@ -3,16 +3,13 @@ package com.github.pakka_papad.restore import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Checkbox import androidx.compose.material.Text -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import com.github.pakka_papad.components.SelectableCard import com.github.pakka_papad.data.music.BlacklistedSong @Composable @@ -22,6 +19,7 @@ fun RestoreContent( paddingValues: PaddingValues, onSelectChanged: (index: Int, isSelected: Boolean) -> Unit, ){ + if (songs.size != selectList.size) return LazyColumn( modifier = Modifier .fillMaxSize(), @@ -42,24 +40,15 @@ fun RestoreContent( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SelectableBlacklistedSong( song: BlacklistedSong, isSelected: Boolean, onSelectChange: (isSelected: Boolean) -> Unit, -){ - Row( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - verticalAlignment = Alignment.CenterVertically, - ){ - Checkbox( - checked = isSelected, - onCheckedChange = onSelectChange - ) - Spacer(Modifier.width(10.dp)) +) = SelectableCard( + isSelected = isSelected, + onSelectChange = onSelectChange, + content = { Column { Text( text = song.title, @@ -78,4 +67,4 @@ fun SelectableBlacklistedSong( ) } } -} \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/restore/RestoreFragment.kt b/app/src/main/java/com/github/pakka_papad/restore/RestoreFragment.kt index 8e9da07..f63995b 100644 --- a/app/src/main/java/com/github/pakka_papad/restore/RestoreFragment.kt +++ b/app/src/main/java/com/github/pakka_papad/restore/RestoreFragment.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -19,6 +20,7 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.fragment.findNavController +import com.github.pakka_papad.components.CancelConfirmTopBar import com.github.pakka_papad.data.ZenPreferenceProvider import com.github.pakka_papad.ui.theme.ZenTheme import dagger.hilt.android.AndroidEntryPoint @@ -48,14 +50,18 @@ class RestoreFragment: Fragment() { ZenTheme(theme) { val songs by viewModel.blackListedSongs.collectAsStateWithLifecycle() val selectList = viewModel.restoreList + val restored by viewModel.restored.collectAsStateWithLifecycle() + LaunchedEffect(key1 = restored){ + if (restored){ + navController.popBackStack() + } + } Scaffold( topBar = { - RestoreTopBar( + CancelConfirmTopBar( onCancelClicked = navController::popBackStack, - onConfirmClicked = { - viewModel.restoreSongs() - navController.popBackStack() - } + onConfirmClicked = viewModel::restoreSongs, + title = "Restore songs" ) }, content = { paddingValues -> diff --git a/app/src/main/java/com/github/pakka_papad/restore/RestoreTopBar.kt b/app/src/main/java/com/github/pakka_papad/restore/RestoreTopBar.kt deleted file mode 100644 index 05b9450..0000000 --- a/app/src/main/java/com/github/pakka_papad/restore/RestoreTopBar.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.pakka_papad.restore - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.Close -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.github.pakka_papad.components.CenterAlignedTopBar - -@Composable -fun RestoreTopBar( - onCancelClicked: () -> Unit, - onConfirmClicked: () -> Unit, -) = CenterAlignedTopBar( - leadingIcon = { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = null, - modifier = Modifier - .padding(16.dp) - .size(30.dp) - .clickable( - interactionSource = remember{ MutableInteractionSource() }, - indication = rememberRipple( - bounded = false, - radius = 25.dp, - ), - onClick = onCancelClicked - ), - tint = MaterialTheme.colorScheme.onSurface, - ) - }, - title = "Restore Songs", - actions = { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = null, - modifier = Modifier - .padding(16.dp) - .size(30.dp) - .clickable( - interactionSource = remember{ MutableInteractionSource() }, - indication = rememberRipple( - bounded = false, - radius = 25.dp, - ), - onClick = onConfirmClicked - ), - tint = MaterialTheme.colorScheme.onSurface - ) - }, - titleMaxLines = 1 -) \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/restore/RestoreViewModel.kt b/app/src/main/java/com/github/pakka_papad/restore/RestoreViewModel.kt index fb1c51c..f06b056 100644 --- a/app/src/main/java/com/github/pakka_papad/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/github/pakka_papad/restore/RestoreViewModel.kt @@ -7,9 +7,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.pakka_papad.data.DataManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -38,6 +41,9 @@ class RestoreViewModel @Inject constructor( _restoreList[index] = isSelected } + private val _restored = MutableStateFlow(false) + val restored = _restored.asStateFlow() + fun restoreSongs(){ viewModelScope.launch { val blacklist = blackListedSongs.value @@ -50,6 +56,8 @@ class RestoreViewModel @Inject constructor( } catch (e: Exception){ Timber.e(e) Toast.makeText(context,"Some error occurred",Toast.LENGTH_SHORT).show() + } finally { + _restored.update { true } } } } diff --git a/app/src/main/java/com/github/pakka_papad/restore_folder/RestoreFolderContent.kt b/app/src/main/java/com/github/pakka_papad/restore_folder/RestoreFolderContent.kt new file mode 100644 index 0000000..232a79c --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/restore_folder/RestoreFolderContent.kt @@ -0,0 +1,56 @@ +package com.github.pakka_papad.restore_folder + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.pakka_papad.components.SelectableCard +import com.github.pakka_papad.data.music.BlacklistedFolder + +@Composable +fun RestoreFoldersContent( + folders: List, + selectList: List, + paddingValues: PaddingValues, + onSelectChanged: (index: Int, isSelected: Boolean) -> Unit, +) { + if (folders.size != selectList.size) return + LazyColumn( + contentPadding = paddingValues, + modifier = Modifier + .fillMaxSize(), + ) { + itemsIndexed( + items = folders, + key = { index, folder -> folder.path } + ) { index, folder -> + SelectableBlacklistedFolder( + folder = folder, + isSelected = selectList[index], + onSelectChange = { onSelectChanged(index, it) } + ) + } + } +} + +@Composable +fun SelectableBlacklistedFolder( + folder: BlacklistedFolder, + isSelected: Boolean, + onSelectChange: (Boolean) -> Unit, +) = SelectableCard( + isSelected = isSelected, + onSelectChange = onSelectChange, + content = { + Text( + text = folder.path, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium + ) + } +) diff --git a/app/src/main/java/com/github/pakka_papad/restore_folder/RestoreFolderFragment.kt b/app/src/main/java/com/github/pakka_papad/restore_folder/RestoreFolderFragment.kt new file mode 100644 index 0000000..748d0b8 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/restore_folder/RestoreFolderFragment.kt @@ -0,0 +1,114 @@ +package com.github.pakka_papad.restore_folder + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.LayoutDirection +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import com.github.pakka_papad.components.CancelConfirmTopBar +import com.github.pakka_papad.data.ZenPreferenceProvider +import com.github.pakka_papad.ui.theme.ZenTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class RestoreFolderFragment: Fragment() { + + private val viewModel: RestoreFolderViewModel by viewModels() + + private lateinit var navController: NavController + + @Inject + lateinit var preferenceProvider: ZenPreferenceProvider + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + navController = findNavController() + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addCategory(Intent.CATEGORY_DEFAULT) + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val themePreference by preferenceProvider.theme.collectAsStateWithLifecycle() + val folders by viewModel.folders.collectAsStateWithLifecycle() + val selectList = viewModel.restoreFolderList + + val restored by viewModel.restored.collectAsStateWithLifecycle() + LaunchedEffect(key1 = restored){ + if (restored){ + navController.popBackStack() + } + } + + ZenTheme(themePreference) { + + Scaffold( + topBar = { + CancelConfirmTopBar( + onCancelClicked = navController::popBackStack, + onConfirmClicked = viewModel::restoreFolders, + title = "Restore folders" + ) + }, + content = { paddingValues -> + val insetsPadding = + WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues() + if (selectList.size != folders.size) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ){ + CircularProgressIndicator() + } + } else { + RestoreFoldersContent( + folders = folders, + paddingValues = PaddingValues( + top = paddingValues.calculateTopPadding(), + start = insetsPadding.calculateStartPadding(LayoutDirection.Ltr), + end = insetsPadding.calculateEndPadding(LayoutDirection.Ltr), + bottom = insetsPadding.calculateBottomPadding() + ), + selectList = selectList, + onSelectChanged = viewModel::updateRestoreList + ) + } + }, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/restore_folder/RestoreFolderViewModel.kt b/app/src/main/java/com/github/pakka_papad/restore_folder/RestoreFolderViewModel.kt new file mode 100644 index 0000000..55d02ca --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/restore_folder/RestoreFolderViewModel.kt @@ -0,0 +1,67 @@ +package com.github.pakka_papad.restore_folder + +import android.app.Application +import android.widget.Toast +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.pakka_papad.data.DataManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RestoreFolderViewModel @Inject constructor( + private val context: Application, + private val manager: DataManager, +): ViewModel() { + + private val _restoreFoldersList = mutableStateListOf() + val restoreFolderList : List = _restoreFoldersList + + val folders = manager.getAll.blacklistedFolders() + .onEach { + while (_restoreFoldersList.size < it.size) _restoreFoldersList.add(false) + while (_restoreFoldersList.size > it.size) _restoreFoldersList.removeLast() + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + + fun updateRestoreList(index: Int, isSelected: Boolean){ + if (index >= _restoreFoldersList.size) return + _restoreFoldersList[index] = isSelected + } + + private val _restored = MutableStateFlow(false) + val restored = _restored.asStateFlow() + + fun restoreFolders(){ + viewModelScope.launch { + val allFolders = folders.value + val toRestore = _restoreFoldersList.indices + .filter { _restoreFoldersList[it] } + .map { allFolders[it] } + try { + manager.removeFoldersFromBlacklist(toRestore) + showToast("Done. Rescan to see all the songs") + } catch (_ : Exception){ + showToast("Some error occurred") + } finally { + _restored.update { true } + } + } + } + + private fun showToast(message: String) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistFragment.kt b/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistFragment.kt index d143fbe..d43f908 100644 --- a/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistFragment.kt +++ b/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistFragment.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs +import com.github.pakka_papad.components.CancelConfirmTopBar import com.github.pakka_papad.data.ZenPreferenceProvider import com.github.pakka_papad.ui.theme.ZenTheme import dagger.hilt.android.AndroidEntryPoint @@ -56,12 +57,13 @@ class SelectPlaylistFragment: Fragment() { val selectList = viewModel.selectList Scaffold( topBar = { - SelectPlaylistTopBar( + CancelConfirmTopBar( onCancelClicked = navController::popBackStack, onConfirmClicked = { viewModel.addSongsToPlaylists(args.songLocations) navController.popBackStack() - } + }, + title = "Select Playlists" ) }, content = { paddingValues -> diff --git a/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistTopBar.kt b/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistTopBar.kt deleted file mode 100644 index 56be3d6..0000000 --- a/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistTopBar.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.pakka_papad.select_playlist - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.Close -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.github.pakka_papad.components.CenterAlignedTopBar - -@Composable -fun SelectPlaylistTopBar( - onCancelClicked: () -> Unit, - onConfirmClicked: () -> Unit, -) = CenterAlignedTopBar( - leadingIcon = { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = null, - modifier = Modifier - .padding(16.dp) - .size(30.dp) - .clickable( - interactionSource = remember{ MutableInteractionSource() }, - indication = rememberRipple( - bounded = false, - radius = 25.dp, - ), - onClick = onCancelClicked - ), - tint = MaterialTheme.colorScheme.onSurface, - ) - }, - title = "Select Playlists", - actions = { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = null, - modifier = Modifier - .padding(16.dp) - .size(30.dp) - .clickable( - interactionSource = remember{ MutableInteractionSource() }, - indication = rememberRipple( - bounded = false, - radius = 25.dp, - ), - onClick = onConfirmClicked - ), - tint = MaterialTheme.colorScheme.onSurface - ) - }, - titleMaxLines = 1 -) \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistViewModel.kt b/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistViewModel.kt index 5805da7..d5c6c72 100644 --- a/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistViewModel.kt +++ b/app/src/main/java/com/github/pakka_papad/select_playlist/SelectPlaylistViewModel.kt @@ -43,11 +43,13 @@ class SelectPlaylistViewModel @Inject constructor( fun addSongsToPlaylists(songLocations: Array) { viewModelScope.launch { val playlists = playlistsWithSongCount.value + val validSongs = songLocations.filter { !manager.blacklistedSongLocations.contains(it) } + val anyBlacklistedSong = songLocations.any { manager.blacklistedSongLocations.contains(it) } val playlistSongCrossRefs = _selectList.indices .filter { _selectList[it] } .map { val list = ArrayList() - for (songLocation in songLocations) { + for (songLocation in validSongs) { list += PlaylistSongCrossRef(playlists[it].playlistId, songLocation) } list.toList() @@ -58,6 +60,10 @@ class SelectPlaylistViewModel @Inject constructor( } catch (e: Exception){ Timber.e(e) Toast.makeText(context,"Some error occurred",Toast.LENGTH_SHORT).show() + } finally { + if (anyBlacklistedSong){ + Toast.makeText(context,"Blacklisted songs have not been added to playlist",Toast.LENGTH_SHORT).show() + } } } } diff --git a/app/src/main/java/com/github/pakka_papad/settings/SettingsFragment.kt b/app/src/main/java/com/github/pakka_papad/settings/SettingsFragment.kt index 00982e4..df323e8 100644 --- a/app/src/main/java/com/github/pakka_papad/settings/SettingsFragment.kt +++ b/app/src/main/java/com/github/pakka_papad/settings/SettingsFragment.kt @@ -48,6 +48,8 @@ class SettingsFragment : Fragment() { val scanStatus by viewModel.scanStatus.collectAsStateWithLifecycle() val isCrashlyticsDisabled by preferenceProvider.isCrashlyticsDisabled.collectAsStateWithLifecycle() + val tabsSelection by viewModel.tabsSelection.collectAsStateWithLifecycle() + val restoreClicked = remember{ { if (navController.currentDestination?.id == R.id.settingsFragment){ navController.navigate(R.id.action_settingsFragment_to_restoreFragment) @@ -58,6 +60,11 @@ class SettingsFragment : Fragment() { navController.navigate(R.id.action_settingsFragment_to_whatsNewFragment) } } } + val onRestoreFoldersClicked = remember{ { + if (navController.currentDestination?.id == R.id.settingsFragment){ + navController.navigate(R.id.action_settingsFragment_to_restoreFolderFragment) + } + } } ZenTheme(themePreference) { Scaffold( @@ -85,7 +92,12 @@ class SettingsFragment : Fragment() { onRestoreClicked = restoreClicked, disabledCrashlytics = isCrashlyticsDisabled, onAutoReportCrashClicked = preferenceProvider::toggleCrashlytics, - onWhatsNewClicked = whatsNewClicked + onWhatsNewClicked = whatsNewClicked, + onRestoreFoldersClicked = onRestoreFoldersClicked, + tabsSelection = tabsSelection, + onTabsSelectChange = viewModel::onTabsSelectChanged, + onTabsOrderChanged = viewModel::onTabsOrderChanged, + onTabsOrderConfirmed = viewModel::saveTabsOrder ) } ) diff --git a/app/src/main/java/com/github/pakka_papad/settings/SettingsList.kt b/app/src/main/java/com/github/pakka_papad/settings/SettingsList.kt index a56f429..ccd6752 100644 --- a/app/src/main/java/com/github/pakka_papad/settings/SettingsList.kt +++ b/app/src/main/java/com/github/pakka_papad/settings/SettingsList.kt @@ -7,16 +7,28 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -29,10 +41,13 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.github.pakka_papad.BuildConfig import com.github.pakka_papad.R -import com.github.pakka_papad.components.OutlinedBox +import com.github.pakka_papad.Screens import com.github.pakka_papad.data.UserPreferences import com.github.pakka_papad.data.UserPreferences.Accent import com.github.pakka_papad.data.music.ScanStatus +import com.github.pakka_papad.nowplaying.DraggableItem +import com.github.pakka_papad.nowplaying.dragContainer +import com.github.pakka_papad.nowplaying.rememberDragDropState import com.github.pakka_papad.ui.theme.ThemePreference import com.github.pakka_papad.ui.theme.getSeedColor import timber.log.Timber @@ -48,27 +63,46 @@ fun SettingsList( disabledCrashlytics: Boolean, onAutoReportCrashClicked: (Boolean) -> Unit, onWhatsNewClicked: () -> Unit, + onRestoreFoldersClicked: () -> Unit, + tabsSelection: List>, + onTabsSelectChange: (Screens, Boolean) -> Unit, + onTabsOrderChanged: (fromIdx: Int, toIdx: Int) -> Unit, + onTabsOrderConfirmed: () -> Unit, ) { LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 10.dp), contentPadding = paddingValues, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { + item { + GroupTitle(title = "Look and feel") + } item { LookAndFeelSettings( themePreference = themePreference, onPreferenceChanged = onThemePreferenceChanged, + tabsSelection = tabsSelection, + onTabsSelectChange = onTabsSelectChange, + onTabsOrderChanged = onTabsOrderChanged, + onTabsOrderConfirmed = onTabsOrderConfirmed, ) } + item { + GroupTitle(title = "Music library") + } item { MusicLibrarySettings( scanStatus = scanStatus, onScanClicked = onScanClicked, - onRestoreClicked = onRestoreClicked + onRestoreClicked = onRestoreClicked, + onRestoreFoldersClicked = onRestoreFoldersClicked ) } + item { + GroupTitle(title = "Report bug") + } item { ReportBug( disabledCrashlytics = disabledCrashlytics, @@ -83,103 +117,113 @@ fun SettingsList( } } +@Composable +fun GroupTitle( + title: String, +){ + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(start = 8.dp, top = 8.dp) + ) +} + @Composable private fun LookAndFeelSettings( themePreference: ThemePreference, onPreferenceChanged: (ThemePreference) -> Unit, + tabsSelection: List>, + onTabsSelectChange: (Screens, Boolean) -> Unit, + onTabsOrderChanged: (fromIdx: Int, toIdx: Int) -> Unit, + onTabsOrderConfirmed: () -> Unit, ) { - val spacerModifier = Modifier.height(10.dp) - OutlinedBox( - label = "Look and feel", - contentPadding = PaddingValues(vertical = 13.dp, horizontal = 20.dp), - modifier = Modifier.padding(10.dp) - ) { - Column(Modifier.fillMaxWidth()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Material You", - style = MaterialTheme.typography.titleMedium - ) - Switch(checked = themePreference.useMaterialYou, onCheckedChange = { - onPreferenceChanged(themePreference.copy(useMaterialYou = it)) - }) - } - Spacer(spacerModifier) - } - val seedColor by remember(themePreference.accent){ - derivedStateOf { - themePreference.accent.getSeedColor() + val seedColor by remember(themePreference.accent){ + derivedStateOf { + themePreference.accent.getSeedColor() + } + } + var showAccentSelector by remember { mutableStateOf(false) } + var showSelectorDialog by remember { mutableStateOf(false) } + val isSystemInDarkMode = isSystemInDarkTheme() + val icon by remember(themePreference.theme) { derivedStateOf { + when (themePreference.theme) { + UserPreferences.Theme.LIGHT_MODE, UserPreferences.Theme.UNRECOGNIZED -> R.drawable.baseline_light_mode_40 + UserPreferences.Theme.DARK_MODE -> R.drawable.baseline_dark_mode_40 + UserPreferences.Theme.USE_SYSTEM_MODE -> { + if (isSystemInDarkMode){ + R.drawable.baseline_dark_mode_40 + } else { + R.drawable.baseline_light_mode_40 } } - var showAccentSelector by remember { mutableStateOf(false) } - Row( - modifier = Modifier - .fillMaxWidth() - .alpha(if (themePreference.useMaterialYou) 0.5f else 1f), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Accent color", - style = MaterialTheme.typography.titleMedium - ) - Canvas( - modifier = Modifier - .size(50.dp) - .clickable( - enabled = (!themePreference.useMaterialYou), - onClick = { showAccentSelector = true } - ) - ){ - drawCircle(color = seedColor) + } + } } + var showRearrangeTabsDialog by remember{ mutableStateOf(false) } + + Column( + modifier = Modifier.group() + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Setting( + title = "Material You", + icon = R.drawable.baseline_palette_40, + description = "Use a theme generated from your device wallpaper", + isChecked = themePreference.useMaterialYou, + onCheckedChanged = { + onPreferenceChanged(themePreference.copy(useMaterialYou = it)) + }, + ) + } + AccentSetting( + onClick = { + if (!themePreference.useMaterialYou) { + showAccentSelector = true } + }, + seedColor = seedColor, + modifier = Modifier + .alpha(if (themePreference.useMaterialYou) 0.5f else 1f), + ) + Setting( + title = "Theme mode", + icon = icon, + onClick = { showSelectorDialog = true }, + description = "Choose a theme mode" + ) + Setting( + title = "Tabs arrangement", + icon = R.drawable.ic_baseline_library_music_40, + description = "Select and reorder the tabs shown", + onClick = { showRearrangeTabsDialog = true } + ) + } + if (showAccentSelector){ + AccentSelectorDialog( + themePreference = themePreference, + onPreferenceChanged = onPreferenceChanged, + onDismissRequest = { showAccentSelector = false } + ) + } + if (showSelectorDialog) { + ThemeSelectorDialog( + themePreference = themePreference, + onPreferenceChanged = onPreferenceChanged, + onDismissRequest = { showSelectorDialog = false } + ) + } + if (showRearrangeTabsDialog){ + RearrangeTabsDialog( + tabsSelection = tabsSelection, + onDismissRequest = { showRearrangeTabsDialog = false }, + onSelectChange = onTabsSelectChange, + onTabsOrderChanged = onTabsOrderChanged, + onTabsOrderConfirmed = { + showRearrangeTabsDialog = false + onTabsOrderConfirmed() } - if (showAccentSelector){ - AccentSelectorDialog( - themePreference = themePreference, - onPreferenceChanged = onPreferenceChanged, - onDismissRequest = { showAccentSelector = false } - ) - } - Spacer(spacerModifier) - var showSelectorDialog by remember { mutableStateOf(false) } - val buttonText = when (themePreference.theme) { - UserPreferences.Theme.LIGHT_MODE, UserPreferences.Theme.UNRECOGNIZED -> "Light" - UserPreferences.Theme.DARK_MODE -> "Dark" - UserPreferences.Theme.USE_SYSTEM_MODE -> "System" - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "App theme", - style = MaterialTheme.typography.titleMedium - ) - Button( - onClick = { showSelectorDialog = true }, - content = { - Text( - text = buttonText, - style = MaterialTheme.typography.titleMedium - ) - } - ) - } - if (showSelectorDialog) { - ThemeSelectorDialog( - themePreference = themePreference, - onPreferenceChanged = onPreferenceChanged, - onDismissRequest = { showSelectorDialog = false } - ) - } - } + ) } } @@ -189,6 +233,7 @@ private fun AccentSelectorDialog( onPreferenceChanged: (ThemePreference) -> Unit, onDismissRequest: () -> Unit, ){ + val sizeModifier = Modifier.size(50.dp) AlertDialog( title = { Text( @@ -218,19 +263,28 @@ private fun AccentSelectorDialog( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - Canvas(Modifier.size(50.dp).clickable { - onPreferenceChanged(themePreference.copy(accent = Accent.Default)) - }) { + Canvas( + modifier = sizeModifier + .clickable { + onPreferenceChanged(themePreference.copy(accent = Accent.Default)) + } + ) { drawCircle(Accent.Default.getSeedColor()) } - Canvas(Modifier.size(50.dp).clickable { - onPreferenceChanged(themePreference.copy(accent = Accent.Malibu)) - }) { + Canvas( + modifier = sizeModifier + .clickable { + onPreferenceChanged(themePreference.copy(accent = Accent.Malibu)) + } + ) { drawCircle(Accent.Malibu.getSeedColor()) } - Canvas(Modifier.size(50.dp).clickable { - onPreferenceChanged(themePreference.copy(accent = Accent.Melrose)) - }) { + Canvas( + modifier = sizeModifier + .clickable { + onPreferenceChanged(themePreference.copy(accent = Accent.Melrose)) + } + ) { drawCircle(Accent.Melrose.getSeedColor()) } } @@ -238,19 +292,28 @@ private fun AccentSelectorDialog( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - Canvas(Modifier.size(50.dp).clickable { - onPreferenceChanged(themePreference.copy(accent = Accent.Elm)) - }) { + Canvas( + modifier = sizeModifier + .clickable { + onPreferenceChanged(themePreference.copy(accent = Accent.Elm)) + } + ) { drawCircle(Accent.Elm.getSeedColor()) } - Canvas(Modifier.size(50.dp).clickable { - onPreferenceChanged(themePreference.copy(accent = Accent.Magenta)) - }) { + Canvas( + modifier = sizeModifier + .clickable { + onPreferenceChanged(themePreference.copy(accent = Accent.Magenta)) + } + ) { drawCircle(Accent.Magenta.getSeedColor()) } - Canvas(Modifier.size(50.dp).clickable { - onPreferenceChanged(themePreference.copy(accent = Accent.JacksonsPurple)) - }) { + Canvas( + modifier = sizeModifier + .clickable { + onPreferenceChanged(themePreference.copy(accent = Accent.JacksonsPurple)) + } + ) { drawCircle(Accent.JacksonsPurple.getSeedColor()) } } @@ -344,84 +407,164 @@ private fun ThemeSelectorDialog( ) } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun MusicLibrarySettings( - scanStatus: ScanStatus, - onScanClicked: () -> Unit, - onRestoreClicked: () -> Unit, -) { - OutlinedBox( - label = "Music library", - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 13.dp), - modifier = Modifier.padding(10.dp) - ) { - Column(Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Rescan for music", - style = MaterialTheme.typography.titleMedium, - ) - Button( - onClick = { - if (scanStatus is ScanStatus.ScanNotRunning){ - onScanClicked() - } - }, - content = { - when (scanStatus) { - is ScanStatus.ScanNotRunning -> { - Text( - text = "Scan", - style = MaterialTheme.typography.bodyLarge, - ) - } - is ScanStatus.ScanComplete -> { - Text( - text = "Done", - style = MaterialTheme.typography.bodyLarge - ) - } - is ScanStatus.ScanProgress -> { - var totalSongs by remember { mutableStateOf(0) } - var scanProgress by remember { mutableStateOf(0f) } - scanProgress = (scanStatus.parsed.toFloat()) / (scanStatus.total.toFloat()) - totalSongs = scanStatus.total - CircularProgressIndicator( - progress = scanProgress, - color = MaterialTheme.colorScheme.onPrimary, - ) - } - else -> {} - } - } - ) - } - Spacer(Modifier.height(10.dp)) - Row( +private fun RearrangeTabsDialog( + tabsSelection: List>, + onDismissRequest: () -> Unit, + onSelectChange: (Screens, Boolean) -> Unit, + onTabsOrderChanged: (fromIdx: Int, toIdx: Int) -> Unit, + onTabsOrderConfirmed: () -> Unit, +){ + AlertDialog( + title = { + Text( + text = "App tabs", + style = MaterialTheme.typography.titleLarge, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + textAlign = TextAlign.Center, + ) + }, + onDismissRequest = onDismissRequest, + confirmButton = { + Button( + onClick = onTabsOrderConfirmed ) { Text( - text = "Restore blacklisted songs", + text = "OK", style = MaterialTheme.typography.titleMedium ) - Button( - onClick = onRestoreClicked, - content = { - Text( - text = "Restore", - style = MaterialTheme.typography.titleMedium + } + }, + text = { + val listState = rememberLazyListState() + val dragDropState = rememberDragDropState( + lazyListState = listState, + onMove = onTabsOrderChanged, + ) + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .dragContainer(dragDropState) + ){ + itemsIndexed( + items = tabsSelection, + key = { index, screenChoice -> screenChoice.first.ordinal } + ){ index, screenChoice -> + DraggableItem(dragDropState, index) { + SelectableMovableScreen( + screen = screenChoice.first, + isSelected = screenChoice.second, + onSelectChange = { isSelected -> onSelectChange(screenChoice.first, isSelected) }, ) } - ) + } } - } + }, + + ) +} + + +@Composable +private fun SelectableMovableScreen( + screen: Screens, + isSelected: Boolean, + onSelectChange: (Boolean) -> Unit, +){ + val spaceModifier = Modifier.width(12.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.secondaryContainer), + verticalAlignment = Alignment.CenterVertically, + ){ + Checkbox( + checked = isSelected, + onCheckedChange = onSelectChange, + ) + Icon( + painter = painterResource(id = screen.filledIcon), + contentDescription = "${screen.name} screen icon", + modifier = Modifier.size(35.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Spacer(spaceModifier) + Text( + text = screen.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.weight(1f) + ) + Spacer(spaceModifier) + Icon( + painter = painterResource(id = R.drawable.baseline_drag_indicator_40), + contentDescription = "move icon", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } +} +@Composable +private fun MusicLibrarySettings( + scanStatus: ScanStatus, + onScanClicked: () -> Unit, + onRestoreClicked: () -> Unit, + onRestoreFoldersClicked: () -> Unit, +) { + var progress by remember { mutableStateOf(0f) } + LaunchedEffect(key1 = scanStatus){ + if (scanStatus is ScanStatus.ScanComplete) { + progress = 1f + } else if (scanStatus is ScanStatus.ScanProgress){ + progress = if (scanStatus.total == 0){ + 1f + } else { + scanStatus.parsed.toFloat()/scanStatus.total.toFloat() + } + } + } + Column( + modifier = Modifier + .group() + .animateContentSize(), + ) { + Setting( + title = "Rescan for music", + icon = Icons.Outlined.Search, + description = "Search for all the songs on this device and update the library", + onClick = { + if (scanStatus is ScanStatus.ScanNotRunning){ + onScanClicked() + } + }, + ) + if (scanStatus is ScanStatus.ScanProgress){ + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + progress = progress + ) + } else if (scanStatus is ScanStatus.ScanComplete){ + Text( + text = "Done", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + } + Setting( + title = "Restore blacklisted songs", + icon = R.drawable.baseline_settings_backup_restore_40, + onClick = onRestoreClicked + ) + Setting( + title = "Restore blacklisted folders", + icon = R.drawable.baseline_settings_backup_restore_40, + onClick = onRestoreFoldersClicked + ) } } @@ -440,63 +583,38 @@ private fun ReportBug( onAutoReportCrashClicked: (Boolean) -> Unit, ){ val context = LocalContext.current - OutlinedBox( - label = "Report", - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 13.dp), - modifier = Modifier.padding(10.dp) + Column( + modifier = Modifier.group() ) { - Column(Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Auto crash reporting", - style = MaterialTheme.typography.titleMedium, - ) - Switch( - checked = !disabledCrashlytics, - onCheckedChange = onAutoReportCrashClicked - ) - } - Spacer(Modifier.height(10.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Report any bugs/crashes", - style = MaterialTheme.typography.titleMedium - ) - Button( - onClick = { - val intent = Intent(Intent.ACTION_SENDTO) - intent.apply { - putExtra(Intent.EXTRA_EMAIL, arrayOf("music.zen@outlook.com")) - putExtra(Intent.EXTRA_SUBJECT, "Zen Music | Bug Report") - putExtra( - Intent.EXTRA_TEXT, - getSystemDetail() + "\n\n[Describe the bug or crash here]" - ) - data = Uri.parse("mailto:") - } - try { - context.startActivity(intent) - } catch (e: Exception) { - Timber.e(e) - } - }, - content = { - Text( - text = "Report", - style = MaterialTheme.typography.titleMedium - ) - } - ) + Setting( + title = "Auto crash reporting", + icon = R.drawable.baseline_send_40, + description = "Enable this to automatically send crash reports to the developer", + isChecked = !disabledCrashlytics, + onCheckedChanged = onAutoReportCrashClicked, + ) + Setting( + title = "Report any bugs/crashes", + icon = R.drawable.baseline_bug_report_40, + description = "Manually report any bugs or crashes you faced", + onClick = { + val intent = Intent(Intent.ACTION_SENDTO) + intent.apply { + putExtra(Intent.EXTRA_EMAIL, arrayOf("music.zen@outlook.com")) + putExtra(Intent.EXTRA_SUBJECT, "Zen Music | Bug Report") + putExtra( + Intent.EXTRA_TEXT, + getSystemDetail() + "\n\n[Describe the bug or crash here]" + ) + data = Uri.parse("mailto:") + } + try { + context.startActivity(intent) + } catch (e: Exception) { + Timber.e(e) + } } - } + ) } } @@ -606,4 +724,175 @@ private fun MadeBy( } Spacer(Modifier.height(36.dp)) } +} + +private fun Modifier.group() = composed { + this + .fillMaxWidth() + .padding(8.dp) + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) +} + + +@Composable +private fun Setting( + title: String, + icon: ImageVector, + modifier: Modifier = Modifier, + description: String? = null, + onClick: (() -> Unit)? = null, + isChecked: Boolean? = null, + onCheckedChanged: ((Boolean) -> Unit)? = null, +){ + Row( + modifier = modifier + .fillMaxWidth() + .then( + if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ) + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(6.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ){ + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + description?.let { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } + isChecked?.let { + Switch( + checked = isChecked, + onCheckedChange = onCheckedChanged, + ) + } + } +} + + +@Composable +private fun Setting( + title: String, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + description: String? = null, + onClick: (() -> Unit)? = null, + isChecked: Boolean? = null, + onCheckedChanged: ((Boolean) -> Unit)? = null, +){ + Row( + modifier = modifier + .fillMaxWidth() + .then( + if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ) + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(6.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ){ + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + description?.let { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } + isChecked?.let { + Switch( + checked = isChecked, + onCheckedChange = onCheckedChanged, + ) + } + } +} + +@Composable +private fun AccentSetting( + onClick: () -> Unit, + seedColor: Color, + modifier: Modifier = Modifier, +){ + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.baseline_colorize_40), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(6.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ){ + Text( + text = "Accent color", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Choose a theme color", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + Box( + modifier = Modifier + .height(32.dp) + .width(52.dp) + .clip(RoundedCornerShape(16.dp)) + .background(color = seedColor) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/settings/SettingsViewModel.kt b/app/src/main/java/com/github/pakka_papad/settings/SettingsViewModel.kt index a85fa9d..3c80155 100644 --- a/app/src/main/java/com/github/pakka_papad/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/github/pakka_papad/settings/SettingsViewModel.kt @@ -1,20 +1,27 @@ package com.github.pakka_papad.settings import android.app.Application +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.pakka_papad.Screens import com.github.pakka_papad.data.DataManager +import com.github.pakka_papad.data.ZenPreferenceProvider import com.github.pakka_papad.data.music.ScanStatus -import com.github.pakka_papad.data.notification.ZenNotificationManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val context: Application, private val manager: DataManager, + private val prefs: ZenPreferenceProvider ) : ViewModel() { val scanStatus = manager.scanStatus @@ -31,4 +38,63 @@ class SettingsViewModel @Inject constructor( manager.scanForMusic() } + private val _tabsSelection = MutableStateFlow>>(listOf()) + val tabsSelection = _tabsSelection.asStateFlow() + + init { + val selectedScreens = prefs.selectedTabs.value ?: listOf() + val allScreens = Screens.values() + val currentSelection = arrayListOf>() + selectedScreens.forEach { + try { + currentSelection += Pair(allScreens[it],true) + } catch (_: Exception){ + + } + } + allScreens.forEach { + if (!selectedScreens.contains(it.ordinal)){ + currentSelection += Pair(it,false) + } + } + _tabsSelection.update { currentSelection.toList() } + } + + fun onTabsSelectChanged(screen: Screens, isSelected: Boolean){ + viewModelScope.launch { + val newSelection = _tabsSelection.value.map { + if (it.first.ordinal == screen.ordinal){ + Pair(it.first, isSelected) + } else { + it + } + } + _tabsSelection.update { newSelection } + } + } + + fun onTabsOrderChanged(fromIndex: Int, toIndex: Int){ + viewModelScope.launch { + val newOrder = _tabsSelection.value.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + }.toList() + _tabsSelection.update { newOrder } + } + } + + fun saveTabsOrder() { + viewModelScope.launch { + val order = _tabsSelection.value.filter { it.second }.map { it.first.ordinal } + if (order.isEmpty()){ + Toast.makeText(context, "Minimum one tab selection is required", Toast.LENGTH_SHORT).show() + } else if (order.size > 5){ + Toast.makeText(context, "Maximum of five tab selections are allowed", Toast.LENGTH_SHORT).show() + } else if(!order.contains(Screens.Songs.ordinal)) { + Toast.makeText(context, "Songs tab cannot be removed", Toast.LENGTH_SHORT).show() + } else { + prefs.updateSelectedTabs(order) + } + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/storage_explorer/Directory.kt b/app/src/main/java/com/github/pakka_papad/storage_explorer/Directory.kt new file mode 100644 index 0000000..eeb12d7 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/storage_explorer/Directory.kt @@ -0,0 +1,6 @@ +package com.github.pakka_papad.storage_explorer + +data class Directory( + val name: String, + val absolutePath: String, +) diff --git a/app/src/main/java/com/github/pakka_papad/storage_explorer/DirectoryContents.kt b/app/src/main/java/com/github/pakka_papad/storage_explorer/DirectoryContents.kt new file mode 100644 index 0000000..9066b12 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/storage_explorer/DirectoryContents.kt @@ -0,0 +1,8 @@ +package com.github.pakka_papad.storage_explorer + +import com.github.pakka_papad.data.music.MiniSong + +data class DirectoryContents( + val directories: List = listOf(), + val songs: List = listOf() +) diff --git a/app/src/main/java/com/github/pakka_papad/storage_explorer/FileExplorer.kt b/app/src/main/java/com/github/pakka_papad/storage_explorer/FileExplorer.kt new file mode 100644 index 0000000..a4d71cb --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/storage_explorer/FileExplorer.kt @@ -0,0 +1,73 @@ +package com.github.pakka_papad.storage_explorer + +import android.os.Environment +import com.github.pakka_papad.data.music.SongExtractor +import java.io.File +import java.io.FileFilter + +class MusicFileExplorer( + private val songExtractor: SongExtractor +) { + + val root = Environment.getExternalStorageDirectory().absolutePath + var currentPath = root + + val isRoot: Boolean + get() { + return root == currentPath + } + + private val filterDirectories = object : FileFilter { + override fun accept(pathname: File?): Boolean { + if (pathname == null || !pathname.exists()) return false + return pathname.isDirectory + } + } + + private val listeners = arrayListOf() + + fun addListener(listener: DirectoryChangeListener){ + listeners.add(listener) + val directory = File(currentPath) + if (!directory.exists() || !directory.isDirectory) return + val directories = (directory.listFiles(filterDirectories) ?: arrayOf()).map { + Directory(name = it.name, absolutePath = it.absolutePath) + } + val songs = songExtractor.extractMini(currentPath) + listener.onDirectoryChanged(currentPath,DirectoryContents(directories,songs)) + } + + fun removeListener(listener: DirectoryChangeListener){ + listeners.remove(listener) + } + + fun moveInsideDirectory(directoryPath: String){ + currentPath = directoryPath + val directory = File(currentPath) + if(!directory.exists() || !directory.isDirectory) return + val directories = (directory.listFiles(filterDirectories) ?: arrayOf()).map { + Directory(name = it.name, absolutePath = it.absolutePath) + } + val songs = songExtractor.extractMini(currentPath) + listeners.forEach { it.onDirectoryChanged(currentPath, DirectoryContents(directories,songs)) } + } + + fun moveToParent(){ + if (currentPath == root) return + var directory = File(currentPath) + directory = directory.parentFile ?: return + if (!directory.exists() || !directory.isDirectory) return + currentPath = directory.absolutePath + val directories = (directory.listFiles(filterDirectories) ?: arrayOf()).map { + Directory(name = it.name, absolutePath = it.absolutePath) + } + val songs = songExtractor.extractMini(currentPath) + listeners.forEach { it.onDirectoryChanged(currentPath, DirectoryContents(directories,songs)) } + } + + + interface DirectoryChangeListener { + fun onDirectoryChanged(path: String, files: DirectoryContents) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/widgets/MusicControlWidget.kt b/app/src/main/java/com/github/pakka_papad/widgets/MusicControlWidget.kt new file mode 100644 index 0000000..db1c959 --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/widgets/MusicControlWidget.kt @@ -0,0 +1,240 @@ +package com.github.pakka_papad.widgets + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.ImageProvider as UriImageProvider +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.layout.wrapContentWidth +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.github.pakka_papad.Constants +import com.github.pakka_papad.R +import com.github.pakka_papad.player.ZenBroadcastReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +val imageUriKey = stringPreferencesKey("image_uri") +val albumKey = stringPreferencesKey("album") +val titleKey = stringPreferencesKey("title") +val artistKey = stringPreferencesKey("artist") +val isPlayingKey = booleanPreferencesKey("is_playing") + +object MusicControlWidget : GlanceAppWidget() { + + private lateinit var pendingPausePlayIntent: PendingIntent + private lateinit var pendingPreviousIntent: PendingIntent + private lateinit var pendingNextIntent: PendingIntent + + override suspend fun provideGlance(context: Context, id: GlanceId) { + pendingPausePlayIntent = PendingIntent.getBroadcast( + context, ZenBroadcastReceiver.PAUSE_PLAY_ACTION_REQUEST_CODE, + Intent(Constants.PACKAGE_NAME).putExtra( + ZenBroadcastReceiver.AUDIO_CONTROL, + ZenBroadcastReceiver.ZEN_PLAYER_PAUSE_PLAY + ), + PendingIntent.FLAG_IMMUTABLE + ) + pendingPreviousIntent = PendingIntent.getBroadcast( + context, ZenBroadcastReceiver.PREVIOUS_ACTION_REQUEST_CODE, + Intent(Constants.PACKAGE_NAME).putExtra( + ZenBroadcastReceiver.AUDIO_CONTROL, + ZenBroadcastReceiver.ZEN_PLAYER_PREVIOUS + ), + PendingIntent.FLAG_IMMUTABLE + ) + pendingNextIntent = PendingIntent.getBroadcast( + context, ZenBroadcastReceiver.NEXT_ACTION_REQUEST_CODE, + Intent(Constants.PACKAGE_NAME).putExtra( + ZenBroadcastReceiver.AUDIO_CONTROL, + ZenBroadcastReceiver.ZEN_PLAYER_NEXT + ), + PendingIntent.FLAG_IMMUTABLE + ) + provideContent { + GlanceTheme { + WidgetContent() + } + } + } + + @Composable + private fun WidgetContent() { + val imageUri = currentState(imageUriKey) ?: "" + val title = currentState(titleKey) ?: "" + val album = currentState(albumKey) ?: "" + val artist = currentState(artistKey) ?: "" + val isPlaying = currentState(isPlayingKey) ?: false + Row( + modifier = GlanceModifier + .fillMaxSize() + .then( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S){ + GlanceModifier + .cornerRadius(28.dp) + .background(GlanceTheme.colors.secondaryContainer) + .padding(12.dp) + } else { + GlanceModifier + .background(ImageProvider(R.drawable.music_widget_background)) + } + ), + ) { + Image( + provider = UriImageProvider(imageUri.toUri()), + contentDescription = null, + modifier = GlanceModifier + .wrapContentWidth() + .fillMaxHeight() + .then( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + GlanceModifier + .cornerRadius(16.dp) + } else { + GlanceModifier + } + ) , + contentScale = ContentScale.Fit + ) + Spacer(GlanceModifier.width(12.dp)) + Column( + modifier = GlanceModifier + .fillMaxSize() + ) { + Text( + text = title, + style = TextStyle( + color = GlanceTheme.colors.onSecondaryContainer, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ), + maxLines = 1, + ) + Text( + text = artist, + style = TextStyle( + color = GlanceTheme.colors.onSecondaryContainer, + fontSize = 14.sp, + fontWeight = FontWeight.Normal + ), + maxLines = 1, + ) + Row( + modifier = GlanceModifier + .fillMaxSize(), + verticalAlignment = Alignment.Bottom, + horizontalAlignment = Alignment.Horizontal.CenterHorizontally + ) { + Image( + provider = ImageProvider(R.drawable.ic_baseline_skip_previous_40), + contentDescription = null, + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSecondaryContainer), + modifier = GlanceModifier + .clickable { + pendingPreviousIntent.send() + } + ) + Spacer(GlanceModifier.width(6.dp)) + Image( + provider = ImageProvider( + if (isPlaying) { + R.drawable.ic_baseline_pause_40 + } else { + R.drawable.ic_baseline_play_arrow_40 + } + ), + contentDescription = null, + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSecondaryContainer), + modifier = GlanceModifier + .clickable { + pendingPausePlayIntent.send() + } + ) + Spacer(GlanceModifier.width(6.dp)) + Image( + provider = ImageProvider(R.drawable.ic_baseline_skip_next_40), + contentDescription = null, + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSecondaryContainer), + modifier = GlanceModifier + .clickable { + pendingNextIntent.send() + } + ) + } + } + } + } +} + +class MusicControlWidgetReceiver : GlanceAppWidgetReceiver() { + + override val glanceAppWidget: GlanceAppWidget + get() = MusicControlWidget + + private val scope = CoroutineScope(SupervisorJob()) + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + val action = intent.extras?.getString(WidgetBroadcast.WIDGET_BROADCAST) ?: return + scope.launch { + GlanceAppWidgetManager(context) + .getGlanceIds(MusicControlWidget.javaClass) + .forEach { glanceId -> + when (action) { + WidgetBroadcast.SONG_CHANGED -> { + updateAppWidgetState(context, glanceId) { prefs -> + prefs[imageUriKey] = intent.getStringExtra("imageUri") ?: "" + prefs[albumKey] = intent.getStringExtra("album") ?: "" + prefs[titleKey] = intent.getStringExtra("title") ?: "" + prefs[artistKey] = intent.getStringExtra("artist") ?: "" + } + } + + WidgetBroadcast.IS_PLAYING_CHANGED -> { + updateAppWidgetState(context, glanceId) { prefs -> + prefs[isPlayingKey] = intent.getBooleanExtra("isPlaying", false) + } + } + + else -> { + + } + } + glanceAppWidget.update(context, glanceId) + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/pakka_papad/widgets/WidgetBroadcast.kt b/app/src/main/java/com/github/pakka_papad/widgets/WidgetBroadcast.kt new file mode 100644 index 0000000..2e1fbec --- /dev/null +++ b/app/src/main/java/com/github/pakka_papad/widgets/WidgetBroadcast.kt @@ -0,0 +1,8 @@ +package com.github.pakka_papad.widgets + +object WidgetBroadcast { + const val WIDGET_BROADCAST = "widget_broadcast" + + const val SONG_CHANGED = "song_changed" + const val IS_PLAYING_CHANGED = "is_playing_changed" +} \ No newline at end of file diff --git a/app/src/main/proto/com/github/pakka_papad/data/UserPreferences.proto b/app/src/main/proto/com/github/pakka_papad/data/UserPreferences.proto index 16b72f9..7d4871f 100644 --- a/app/src/main/proto/com/github/pakka_papad/data/UserPreferences.proto +++ b/app/src/main/proto/com/github/pakka_papad/data/UserPreferences.proto @@ -27,4 +27,6 @@ message UserPreferences { int32 playbackPitch = 2; } PlaybackParams playbackParams = 6; + repeated int32 selectedTabs = 7; + map chosenSortOrder = 8; } \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_bug_report_40.xml b/app/src/main/res/drawable/baseline_bug_report_40.xml new file mode 100644 index 0000000..5e89aee --- /dev/null +++ b/app/src/main/res/drawable/baseline_bug_report_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_colorize_40.xml b/app/src/main/res/drawable/baseline_colorize_40.xml new file mode 100644 index 0000000..a37a6a2 --- /dev/null +++ b/app/src/main/res/drawable/baseline_colorize_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_dark_mode_40.xml b/app/src/main/res/drawable/baseline_dark_mode_40.xml new file mode 100644 index 0000000..cd27819 --- /dev/null +++ b/app/src/main/res/drawable/baseline_dark_mode_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_drag_indicator_40.xml b/app/src/main/res/drawable/baseline_drag_indicator_40.xml new file mode 100644 index 0000000..63f57ba --- /dev/null +++ b/app/src/main/res/drawable/baseline_drag_indicator_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_light_mode_40.xml b/app/src/main/res/drawable/baseline_light_mode_40.xml new file mode 100644 index 0000000..72cae6d --- /dev/null +++ b/app/src/main/res/drawable/baseline_light_mode_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_palette_40.xml b/app/src/main/res/drawable/baseline_palette_40.xml new file mode 100644 index 0000000..051b536 --- /dev/null +++ b/app/src/main/res/drawable/baseline_palette_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_send_40.xml b/app/src/main/res/drawable/baseline_send_40.xml new file mode 100644 index 0000000..83d7f27 --- /dev/null +++ b/app/src/main/res/drawable/baseline_send_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_settings_backup_restore_40.xml b/app/src/main/res/drawable/baseline_settings_backup_restore_40.xml new file mode 100644 index 0000000..a8dc5f7 --- /dev/null +++ b/app/src/main/res/drawable/baseline_settings_backup_restore_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_folder_40.xml b/app/src/main/res/drawable/ic_baseline_folder_40.xml new file mode 100644 index 0000000..6df8b5f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_folder_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_folder_40.xml b/app/src/main/res/drawable/ic_outline_folder_40.xml new file mode 100644 index 0000000..048730e --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_folder_40.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/music_widget_background.xml b/app/src/main/res/drawable/music_widget_background.xml new file mode 100644 index 0000000..ba65062 --- /dev/null +++ b/app/src/main/res/drawable/music_widget_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/music_control_widget_preview_layout.xml b/app/src/main/res/layout/music_control_widget_preview_layout.xml new file mode 100644 index 0000000..9dc447f --- /dev/null +++ b/app/src/main/res/layout/music_control_widget_preview_layout.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/app_nav.xml b/app/src/main/res/navigation/app_nav.xml index d3b1ba3..b446a28 100644 --- a/app/src/main/res/navigation/app_nav.xml +++ b/app/src/main/res/navigation/app_nav.xml @@ -73,6 +73,13 @@ app:exitAnim="@anim/zen_open_exit" app:popEnterAnim="@anim/zen_close_enter" app:popExitAnim="@anim/zen_close_exit" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4dc57f0..d720981 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Zen Music + Music Control Widget \ No newline at end of file diff --git a/app/src/main/res/xml/music_control_widget_info.xml b/app/src/main/res/xml/music_control_widget_info.xml new file mode 100644 index 0000000..e305645 --- /dev/null +++ b/app/src/main/res/xml/music_control_widget_info.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/buildSrc/build/pluginUnderTestMetadata/plugin-under-test-metadata.properties b/buildSrc/build/pluginUnderTestMetadata/plugin-under-test-metadata.properties deleted file mode 100644 index 1a49aa1..0000000 --- a/buildSrc/build/pluginUnderTestMetadata/plugin-under-test-metadata.properties +++ /dev/null @@ -1 +0,0 @@ -implementation-classpath=D\:/Zen/Zen/buildSrc/build/classes/java/main;D\:/Zen/Zen/buildSrc/build/classes/groovy/main;D\:/Zen/Zen/buildSrc/build/classes/kotlin/main;D\:/Zen/Zen/buildSrc/build/resources/main diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 945788a..b49c81f 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -13,6 +13,7 @@ object Versions { const val androidxEspresso = "3.4.0" const val androidxComposeConstraintLayout = "1.0.1" const val androidxSplashScreen = "1.0.0-beta02" + const val androidxGlance = "1.0.0-beta01" const val appCompat = "1.4.2" const val navigation = "2.5.3" @@ -25,7 +26,7 @@ object Versions { const val googleServices = "4.3.15" const val crashlyticsGradlePlugin = "2.9.4" - const val material3 = "1.0.1" + const val material3 = "1.1.2" const val accompanist = "0.28.0" const val junit = "4.13.2" @@ -67,6 +68,10 @@ object Libraries { const val androidxComposeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4:${Versions.androidxComposeUi}" const val androidxComposeConstraintLayout = "androidx.constraintlayout:constraintlayout-compose:${Versions.androidxComposeConstraintLayout}" const val androidxSplashScreen = "androidx.core:core-splashscreen:${Versions.androidxSplashScreen}" + const val androidxGlance = "androidx.glance:glance:${Versions.androidxGlance}" + const val androidxGlanceAppWidget = "androidx.glance:glance-appwidget:${Versions.androidxGlance}" + const val androidxGlanceMaterial = "androidx.glance:glance-material:${Versions.androidxGlance}" + const val androidxGlanceMaterial3 = "androidx.glance:glance-material3:${Versions.androidxGlance}" const val roomRuntime = "androidx.room:room-runtime:${Versions.room}" const val roomKtx = "androidx.room:room-ktx:${Versions.room}" @@ -122,7 +127,10 @@ object AnnotationProcessors { } object AppVersion { - const val Code = 2 - const val Name = "1.1" + private const val Major = 1 + private const val Minor = 2 + private const val Patch = 0 + const val Code = Major*10000 + Minor*100 + Patch + const val Name = "$Major.$Minor.$Patch" }