diff --git a/.gitignore b/.gitignore index 3c153c153..734d9bc69 100755 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,5 @@ lint/tmp/ .DS_Store /app/release/ +app/src/main/java/com/dd3boh/outertune/utils/scanners/jni/ffmpeg-android-maker +app/src/main/java/com/dd3boh/outertune/utils/scanners/jni/src/ diff --git a/README.md b/README.md index bd27b9677..6e60119c3 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ A Material 3 YouTube Music client for Android - Library management - Cache and download songs for offline playback - Personalized quick picks -- Synchronized lyrics +- Local media playback + - Multi artist support (non MediaStore tag extractor) +- Synchronized lyrics (LRC format, also includes multi-line support) - Audio normalization, tempo/pitch adjustment, and various other audio effects - Dynamic Material theme & localization - New integrated library screen design @@ -60,6 +62,23 @@ recommend [Pano Scrobbler](https://play.google.com/store/apps/details?id=com.arn Follow the [instructions](https://developer.android.com/guide/topics/resources/localization) and create a pull request. If possible, please build the app beforehand and make sure there is no error before you create a pull request. +./app/src/main/java/com/dd3boh/outertune/utils/scanners/jni/ffmpeg-android-maker +## Building with FFmpeg (non-kit) + +By default, we shit a prebuilt library (`/app/prebuilt/ffMetadataEx.arr`), and you *do not* need to care about this. +However, should you choose to opt for self built libraries and/or work on the extractor itself, keep reading: + +1. First you will need to setup the [Android NDK](https://developer.android.com/studio/projects/install-ndk) + +2. We use FFMpeg to extract metadata from local files. The FFMpeg (non-kit) implementation must be resolved in one of two ways: + + - a) Build libraries. Clone [ffmpeg-android-maker](https://github.com/Javernaut/ffmpeg-android-maker) into `/ffMetadataEx/src/main/cpp/ffmpeg-android-maker`, run the build script. Note: It may be helpful to modify the FFmpeg build script disable uneeded FFmpeg fetaures to reduce app size, see [here](https://github.com/mikooomich/ffmpeg-android-maker/blob/master/scripts/ffmpeg/build.sh) for an example. + + - b) Use prebuilt FFmpeg libraries. Clone [prebuilt ffmpeg-android-maker](https://github.com/mikooomich/ffmpeg-android-maker) into `/ffMetadataEx/src/main/cpp/ffmpeg-android-maker`. + +3. Modify `app/build.gradle.kts` and `settings.gradle.kts` to switch to the self built version, with the instructions being in both of the files + +Then start the build are you normally would. ## Donate diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70fdb31dd..4e33192b4 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { defaultConfig { applicationId = "com.dd3boh.outertune" - minSdk = 21 + minSdk = 24 targetSdk = 34 versionCode = 19 versionName = "0.5.3" @@ -120,4 +120,14 @@ dependencies { coreLibraryDesugaring(libs.desugaring) implementation(libs.timber) + + /** + * Custom FFmpeg metadata extractor + * + * My boss has requested prebuilt libraries by default. Shall you choose + * to work on the scanner itself, switch the implementation below AND + * include the project (uncomment the include line) in /settings.gradle.kts + */ + implementation(files("prebuilt/ffMetadataEx-release.aar")) // prebuilt +// implementation(project(":ffMetadataEx")) // self built } \ No newline at end of file diff --git a/app/prebuilt/ffMetadataEx-release.aar b/app/prebuilt/ffMetadataEx-release.aar new file mode 100644 index 000000000..740b20c09 Binary files /dev/null and b/app/prebuilt/ffMetadataEx-release.aar differ diff --git a/app/schemas/com.dd3boh.outertune.db.InternalDatabase/1.json b/app/schemas/com.dd3boh.outertune.db.InternalDatabase/1.json index 85d74b041..f68d317b6 100644 --- a/app/schemas/com.dd3boh.outertune.db.InternalDatabase/1.json +++ b/app/schemas/com.dd3boh.outertune.db.InternalDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "ffcb09ea8afcb091239f073ebc021c7e", + "identityHash": "8be7f629fa3d0170044e19ffbe1669a9", "entities": [ { "tableName": "song", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `localPath` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -67,6 +67,19 @@ "columnName": "inLibrary", "affinity": "INTEGER", "notNull": false + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": false } ], "primaryKey": { @@ -90,7 +103,7 @@ }, { "tableName": "artist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -127,6 +140,13 @@ "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" } ], "primaryKey": { @@ -140,7 +160,7 @@ }, { "tableName": "album", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -201,6 +221,13 @@ "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" } ], "primaryKey": { @@ -214,7 +241,7 @@ }, { "tableName": "playlist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `isEditable` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `thumbnailUrl` TEXT, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `isEditable` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `thumbnailUrl` TEXT, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -275,6 +302,13 @@ "columnName": "radioEndpointParams", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" } ], "primaryKey": { @@ -878,7 +912,7 @@ ], "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, 'ffcb09ea8afcb091239f073ebc021c7e')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8be7f629fa3d0170044e19ffbe1669a9')" ] } } \ No newline at end of file diff --git a/app/schemas/com.dd3boh.outertune.db.InternalDatabase/13.json b/app/schemas/com.dd3boh.outertune.db.InternalDatabase/13.json index 5fe6768b8..cc13d419c 100644 --- a/app/schemas/com.dd3boh.outertune.db.InternalDatabase/13.json +++ b/app/schemas/com.dd3boh.outertune.db.InternalDatabase/13.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 13, - "identityHash": "322eee64a08c3369d4dec5dbd7c2efee", + "identityHash": "3158aac19867a81982b62ce3e0528ff4", "entities": [ { "tableName": "song", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `localPath` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -67,6 +67,19 @@ "columnName": "inLibrary", "affinity": "INTEGER", "notNull": false + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": false } ], "primaryKey": { @@ -90,7 +103,7 @@ }, { "tableName": "artist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -127,6 +140,13 @@ "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" } ], "primaryKey": { @@ -140,7 +160,7 @@ }, { "tableName": "album", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -201,6 +221,13 @@ "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" } ], "primaryKey": { @@ -214,7 +241,7 @@ }, { "tableName": "playlist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `isEditable` INTEGER, `bookmarkedAt` INTEGER, `thumbnailUrl` TEXT, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `test` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `isEditable` INTEGER, `bookmarkedAt` INTEGER, `thumbnailUrl` TEXT, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -277,10 +304,11 @@ "notNull": false }, { - "fieldPath": "test", - "columnName": "test", + "fieldPath": "isLocal", + "columnName": "isLocal", "affinity": "INTEGER", - "notNull": true + "notNull": true, + "defaultValue": "false" } ], "primaryKey": { @@ -884,7 +912,7 @@ ], "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, '322eee64a08c3369d4dec5dbd7c2efee')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3158aac19867a81982b62ce3e0528ff4')" ] } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 55f1b8495..00e72570c 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,9 @@ + + diff --git a/app/src/main/java/com/dd3boh/outertune/MainActivity.kt b/app/src/main/java/com/dd3boh/outertune/MainActivity.kt index 737768452..5a42ca9c9 100644 --- a/app/src/main/java/com/dd3boh/outertune/MainActivity.kt +++ b/app/src/main/java/com/dd3boh/outertune/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.content.pm.PackageManager import android.graphics.drawable.BitmapDrawable import android.os.Build import android.os.Bundle @@ -50,6 +51,10 @@ import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastForEach import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import android.Manifest +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi import androidx.core.net.toUri import androidx.core.util.Consumer import androidx.core.view.WindowCompat @@ -88,6 +93,7 @@ import com.dd3boh.outertune.ui.screens.library.LibraryAlbumsScreen import com.dd3boh.outertune.ui.screens.library.LibraryArtistsScreen import com.dd3boh.outertune.ui.screens.library.LibraryPlaylistsScreen import com.dd3boh.outertune.ui.screens.library.LibraryScreen +import com.dd3boh.outertune.ui.screens.library.LibrarySongsFolderScreen import com.dd3boh.outertune.ui.screens.library.LibrarySongsScreen import com.dd3boh.outertune.ui.screens.playlist.AutoPlaylistScreen import com.dd3boh.outertune.ui.screens.playlist.LocalPlaylistScreen @@ -98,16 +104,20 @@ import com.dd3boh.outertune.ui.screens.search.OnlineSearchScreen import com.dd3boh.outertune.ui.screens.settings.* import com.dd3boh.outertune.ui.theme.* import com.dd3boh.outertune.ui.utils.appBarScrollBehavior -import com.dd3boh.outertune.ui.utils.backToMain -import com.dd3boh.outertune.ui.utils.canNavigateUp +import com.dd3boh.outertune.ui.utils.localToRemoteArtist +import com.dd3boh.outertune.ui.utils.quickSync import com.dd3boh.outertune.ui.utils.resetHeightOffset +import com.dd3boh.outertune.ui.utils.scanLocal +import com.dd3boh.outertune.ui.utils.unloadScanner import com.dd3boh.outertune.utils.SyncUtils import com.dd3boh.outertune.utils.dataStore import com.dd3boh.outertune.utils.get +import com.dd3boh.outertune.utils.purgeCache import com.dd3boh.outertune.utils.rememberEnumPreference import com.dd3boh.outertune.utils.rememberPreference import com.dd3boh.outertune.utils.reportException import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -141,6 +151,19 @@ class MainActivity : ComponentActivity() { } } + // storage permission helpers + private val mediaPermissionLevel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO + else Manifest.permission.READ_EXTERNAL_STORAGE + + val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { +// Toast.makeText(this, "Granted", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "Permission denied", Toast.LENGTH_SHORT).show() + } + } + override fun onStart() { super.onStart() startService(Intent(this, MusicService::class.java)) @@ -151,8 +174,10 @@ class MainActivity : ComponentActivity() { unbindService(serviceConnection) super.onStop() } - - @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "CoroutineCreationDuringComposition", + "StateFlowValueCalledInComposition" + ) @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -195,6 +220,43 @@ class MainActivity : ComponentActivity() { } } + // Check if the permissions for local media access + if (checkSelfPermission(mediaPermissionLevel) == PackageManager.PERMISSION_GRANTED) { + val (scannerType) = rememberEnumPreference( + key = ScannerTypeKey, + defaultValue = ScannerImpl.MEDIASTORE + ) + val (scannerSensitivity) = rememberEnumPreference( + key = ScannerSensitivityKey, + defaultValue = ScannerMatchCriteria.LEVEL_2 + ) + val (strictExtensions) = rememberPreference(ScannerStrictExtKey, defaultValue = false) + val (lookupYtmArtists) = rememberPreference(LookupYtmArtistsKey, defaultValue = true) + val (autoScan) = rememberPreference(AutomaticScannerKey, defaultValue = true) + + if (autoScan) { + // equivalent to (quick scan) + val directoryStructure = scanLocal(this, database, ScannerImpl.MEDIASTORE).value + quickSync( + database, directoryStructure.toList(), scannerSensitivity, + strictExtensions, scannerType + ) + unloadScanner() + + // start artist linking job + if (lookupYtmArtists) { + CoroutineScope(Dispatchers.IO).launch { + localToRemoteArtist(database) + } + } + purgeCache() // juuuust to be sure + } + } + else if (checkSelfPermission(mediaPermissionLevel) == PackageManager.PERMISSION_DENIED) { + // Request the permission using the permission launcher + permissionLauncher.launch(mediaPermissionLevel) + } + OuterTuneTheme( darkTheme = useDarkTheme, pureBlack = pureBlack, @@ -536,6 +598,10 @@ class MainActivity : ComponentActivity() { composable("new_release") { NewReleaseScreen(navController, scrollBehavior) } + composable(Screens.SongFolders.route) { + LibrarySongsFolderScreen(navController) + } + composable( route = "search/{query}", arguments = listOf( @@ -656,6 +722,9 @@ class MainActivity : ComponentActivity() { composable("settings/player") { PlayerSettings(navController, scrollBehavior) } + composable("settings/player/lyrics") { + LyricsSettings(navController, scrollBehavior) + } composable("settings/storage") { StorageSettings(navController, scrollBehavior) } @@ -665,6 +734,12 @@ class MainActivity : ComponentActivity() { composable("settings/backup_restore") { BackupAndRestore(navController, scrollBehavior) } + composable("settings/local") { + LocalPlayerSettings(navController, scrollBehavior, this@MainActivity, database) + } + composable("settings/experimental") { + ExperimentalSettings(navController, scrollBehavior) + } composable("settings/about") { AboutScreen(navController, scrollBehavior) } diff --git a/app/src/main/java/com/dd3boh/outertune/constants/Dimensions.kt b/app/src/main/java/com/dd3boh/outertune/constants/Dimensions.kt index d491d0011..73c1907b7 100644 --- a/app/src/main/java/com/dd3boh/outertune/constants/Dimensions.kt +++ b/app/src/main/java/com/dd3boh/outertune/constants/Dimensions.kt @@ -11,6 +11,7 @@ const val CONTENT_TYPE_SONG = 2 const val CONTENT_TYPE_ARTIST = 3 const val CONTENT_TYPE_ALBUM = 4 const val CONTENT_TYPE_PLAYLIST = 5 +const val CONTENT_TYPE_FOLDER = 6 val NavigationBarHeight = 80.dp val MiniPlayerHeight = 64.dp diff --git a/app/src/main/java/com/dd3boh/outertune/constants/PreferenceKeys.kt b/app/src/main/java/com/dd3boh/outertune/constants/PreferenceKeys.kt index 6b0f8b158..e901bb301 100644 --- a/app/src/main/java/com/dd3boh/outertune/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/dd3boh/outertune/constants/PreferenceKeys.kt @@ -133,6 +133,37 @@ val AccountNameKey = stringPreferencesKey("accountName") val AccountEmailKey = stringPreferencesKey("accountEmail") val AccountChannelHandleKey = stringPreferencesKey("accountChannelHandle") +// local playback +val ScannerSensitivityKey = stringPreferencesKey("scannerSensitivity") +val ScannerTypeKey = stringPreferencesKey("scannerType") + +/** + * Specify how strict the metadata scanner should be + */ +enum class ScannerMatchCriteria { + LEVEL_1, // Title only + LEVEL_2, // Title and artists + LEVEL_3, // Title, artists, albums +} + +/** + * + */ +enum class ScannerImpl { + MEDIASTORE, + MEDIASTORE_FFPROBE, + FFPROBE, +} + +val ScannerStrictExtKey = booleanPreferencesKey("scannerStrictExt") +val AutomaticScannerKey = booleanPreferencesKey("autoLocalScanner") +val LookupYtmArtistsKey = booleanPreferencesKey("lookupYtmArtists") +val FlatSubfoldersKey = booleanPreferencesKey("flatSubfolders") +val MultilineLrcKey = booleanPreferencesKey("multilineLrc") +val LyricTrimKey = booleanPreferencesKey("lyricTrim") + +val DevSettingsKey = booleanPreferencesKey("devSettings") + val LanguageCodeToName = mapOf( "af" to "Afrikaans", "az" to "Azərbaycan", diff --git a/app/src/main/java/com/dd3boh/outertune/db/DatabaseDao.kt b/app/src/main/java/com/dd3boh/outertune/db/DatabaseDao.kt index d20d389de..3e6331c6f 100644 --- a/app/src/main/java/com/dd3boh/outertune/db/DatabaseDao.kt +++ b/app/src/main/java/com/dd3boh/outertune/db/DatabaseDao.kt @@ -245,6 +245,18 @@ interface DatabaseDao { @Query("SELECT * FROM song") fun allSongs(): Flow> + @Transaction + @Query("SELECT * FROM song WHERE isLocal = 1 and inLibrary IS NOT NULL") + fun allLocalSongs(): Flow> + + @Transaction + @Query("SELECT * FROM artist WHERE isLocal != 1") + fun allRemoteArtists(): Flow> + + @Transaction + @Query("SELECT * FROM artist WHERE isLocal = 1") + fun allLocalArtists(): Flow> + @Query("SELECT * FROM format WHERE id = :id") fun format(id: String?): Flow @@ -327,7 +339,7 @@ interface DatabaseDao { ArtistSortType.PLAY_TIME -> artistsByPlayTimeAsc() }.map { artists -> artists - .filter { it.artist.isYouTubeArtist } + .filter { it.artist.isYouTubeArtist || it.artist.isLocalArtist } // temp: add ui to filter by local or remote or something idk .reversed(descending) } @@ -489,6 +501,10 @@ interface DatabaseDao { @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND inLibrary IS NOT NULL LIMIT :previewSize") fun searchSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + @Transaction + @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' LIMIT :previewSize") + fun searchSongsInclNotInLibrary(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + @Transaction @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' AND songCount > 0 LIMIT :previewSize") fun searchArtists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> @@ -583,7 +599,8 @@ interface DatabaseDao { insert( ArtistEntity( id = artistId, - name = artist.name + name = artist.name, + isLocal = artist.isLocal ) ) insert( @@ -746,6 +763,14 @@ interface DatabaseDao { )) } + @Transaction + @Query("UPDATE song_artist_map SET artistId = :newId WHERE artistId = :oldId") + fun updateSongArtistMap(oldId: String, newId: String) + + @Transaction + @Query("UPDATE album_artist_map SET artistId = :newId WHERE artistId = :oldId") + fun updateAlbumArtistMap(oldId: String, newId: String) + @Upsert fun upsert(map: SongAlbumMap) @@ -779,6 +804,29 @@ interface DatabaseDao { @Delete fun delete(event: Event) + @Transaction + @Query("DELETE FROM song_artist_map WHERE songId = :songID") + fun unlinkSongArtists(songID: String) + + @Transaction + @Query("DELETE FROM song WHERE isLocal IS NOT NULL") + fun nukeLocalSongs() + + @Transaction + @Query("DELETE FROM artist WHERE isLocal IS NOT NULL") + fun nukeLocalArtists() + +// @Transaction +// @Query("DELETE FROM album WHERE isLocal IS NOT NULL") +// fun nukeLocalAlbums() + + @Transaction + fun nukeLocalData() { + nukeLocalSongs() + nukeLocalArtists() +// nukeLocalAlbums() + } + @Query("SELECT * FROM playlist_song_map WHERE songId = :songId") fun playlistSongMaps(songId: String): List diff --git a/app/src/main/java/com/dd3boh/outertune/db/entities/AlbumEntity.kt b/app/src/main/java/com/dd3boh/outertune/db/entities/AlbumEntity.kt index b9d0319c9..ba2ffdc3d 100644 --- a/app/src/main/java/com/dd3boh/outertune/db/entities/AlbumEntity.kt +++ b/app/src/main/java/com/dd3boh/outertune/db/entities/AlbumEntity.kt @@ -1,6 +1,7 @@ package com.dd3boh.outertune.db.entities import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import com.zionhuang.innertube.YouTube @@ -8,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import org.apache.commons.lang3.RandomStringUtils import java.time.LocalDateTime @Immutable @@ -23,7 +25,11 @@ data class AlbumEntity( val duration: Int, val lastUpdateTime: LocalDateTime = LocalDateTime.now(), val bookmarkedAt: LocalDateTime? = null, + @ColumnInfo(name = "isLocal", defaultValue = "false") val isLocal: Boolean = false ) { + val isLocalAlbum: Boolean + get() = id.startsWith("LA") + fun localToggleLike() = copy( bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now() ) @@ -35,4 +41,8 @@ data class AlbumEntity( this.cancel() } } + + companion object { + fun generateAlbumId() = "LA" + RandomStringUtils.random(8, true, false) + } } \ No newline at end of file diff --git a/app/src/main/java/com/dd3boh/outertune/db/entities/ArtistEntity.kt b/app/src/main/java/com/dd3boh/outertune/db/entities/ArtistEntity.kt index 3dc64fdd3..b2a8c4e0f 100644 --- a/app/src/main/java/com/dd3boh/outertune/db/entities/ArtistEntity.kt +++ b/app/src/main/java/com/dd3boh/outertune/db/entities/ArtistEntity.kt @@ -1,6 +1,7 @@ package com.dd3boh.outertune.db.entities import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import com.zionhuang.innertube.YouTube @@ -20,6 +21,7 @@ data class ArtistEntity( val channelId: String? = null, val lastUpdateTime: LocalDateTime = LocalDateTime.now(), val bookmarkedAt: LocalDateTime? = null, + @ColumnInfo(name = "isLocal", defaultValue = "false") val isLocal: Boolean = false ) { val isYouTubeArtist: Boolean get() = id.startsWith("UC") diff --git a/app/src/main/java/com/dd3boh/outertune/db/entities/PlaylistEntity.kt b/app/src/main/java/com/dd3boh/outertune/db/entities/PlaylistEntity.kt index 5d8df604b..64b77dda1 100644 --- a/app/src/main/java/com/dd3boh/outertune/db/entities/PlaylistEntity.kt +++ b/app/src/main/java/com/dd3boh/outertune/db/entities/PlaylistEntity.kt @@ -25,7 +25,8 @@ data class PlaylistEntity( val remoteSongCount: Int? = null, val playEndpointParams: String? = null, val shuffleEndpointParams: String? = null, - val radioEndpointParams: String? = null + val radioEndpointParams: String? = null, + @ColumnInfo(name = "isLocal", defaultValue = "false") val isLocal: Boolean = false, ) { companion object { const val LIKED_PLAYLIST_ID = "LP_LIKED" @@ -34,6 +35,9 @@ data class PlaylistEntity( fun generatePlaylistId() = "LP" + RandomStringUtils.random(8, true, false) } + val isLocalPlaylist: Boolean + get() = id.startsWith("LP") + val shareLink: String? get() { return if (browseId != null) @@ -52,4 +56,6 @@ data class PlaylistEntity( this.cancel() } } + + } diff --git a/app/src/main/java/com/dd3boh/outertune/db/entities/SongEntity.kt b/app/src/main/java/com/dd3boh/outertune/db/entities/SongEntity.kt index a329535e7..ead5c70b3 100644 --- a/app/src/main/java/com/dd3boh/outertune/db/entities/SongEntity.kt +++ b/app/src/main/java/com/dd3boh/outertune/db/entities/SongEntity.kt @@ -1,6 +1,7 @@ package com.dd3boh.outertune.db.entities import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -9,6 +10,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import org.apache.commons.lang3.RandomStringUtils import java.time.LocalDateTime @Immutable @@ -31,7 +33,12 @@ data class SongEntity( val likedDate: LocalDateTime? = null, val totalPlayTime: Long = 0, // in milliseconds val inLibrary: LocalDateTime? = null, + @ColumnInfo(name = "isLocal", defaultValue = "false") val isLocal: Boolean = false, + val localPath: String?, ) { + val isLocalSong: Boolean + get() = id.startsWith("LA") + fun localToggleLike() = copy( liked = !liked, likedDate = if (!liked) LocalDateTime.now() else null, @@ -49,4 +56,8 @@ data class SongEntity( } fun toggleLibrary() = copy(inLibrary = if (inLibrary == null) LocalDateTime.now() else null) + + companion object { + fun generateSongId() = "LA" + RandomStringUtils.random(8, true, false) + } } diff --git a/app/src/main/java/com/dd3boh/outertune/lyrics/LocalLyricsProvider.kt b/app/src/main/java/com/dd3boh/outertune/lyrics/LocalLyricsProvider.kt new file mode 100644 index 000000000..6a43e2054 --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/lyrics/LocalLyricsProvider.kt @@ -0,0 +1,35 @@ +package com.dd3boh.outertune.lyrics + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import java.nio.file.Files +import java.nio.file.Paths + + +object LocalLyricsProvider : LyricsProvider { + override val name = "Local LRC" + override fun isEnabled(context: Context) = true + + /** + * This function is "hot-wired" to adapted to the + * interface design. As a result, title is actually the file path. + * The lrc file is assumed to be in the same directory as the song. + * All the other fields serve no purpose. + * + * @param title file path of the song, NOT the song title + */ + @RequiresApi(Build.VERSION_CODES.O) + override suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int, + ): Result = runCatching { + // ex .../music/song.ogg -> .../music/song.lrc + String(Files.readAllBytes( + Paths.get(title.substringBeforeLast('.') + ".lrc")) + ) + } + +} diff --git a/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsEntry.kt b/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsEntry.kt index 1d05d76d9..389d0ee2a 100644 --- a/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsEntry.kt +++ b/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsEntry.kt @@ -3,6 +3,7 @@ package com.dd3boh.outertune.lyrics data class LyricsEntry( val time: Long, val text: String, + var isTranslation: Boolean = false ) : Comparable { override fun compareTo(other: LyricsEntry): Int = (time - other.time).toInt() diff --git a/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsHelper.kt b/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsHelper.kt index 9b38dfa88..9c2cf99f5 100644 --- a/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsHelper.kt +++ b/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsHelper.kt @@ -1,6 +1,7 @@ package com.dd3boh.outertune.lyrics import android.content.Context +import android.os.Build import android.util.LruCache import com.dd3boh.outertune.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND import com.dd3boh.outertune.models.MediaMetadata @@ -8,6 +9,8 @@ import com.dd3boh.outertune.utils.reportException import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +// true will prioritize local lyric files over all cloud providers, true is vice versa +private const val PREFER_LOCAL_LYRIC = true class LyricsHelper @Inject constructor( @ApplicationContext private val context: Context, ) { @@ -19,6 +22,38 @@ class LyricsHelper @Inject constructor( if (cached != null) { return cached.lyrics } + + val localLyrics = getLocalLyrics(mediaMetadata) + var remoteLyrics: String? + + // fallback to secondary provider when primary is unavailable + if (PREFER_LOCAL_LYRIC) { + if (localLyrics != null) { + return localLyrics + } + + // "lazy eval" the remote lyrics cuz it is laughably slow + remoteLyrics= getRemoteLyrics(mediaMetadata) + if (remoteLyrics != null) { + return remoteLyrics + } + } else { + remoteLyrics= getRemoteLyrics(mediaMetadata) + if (remoteLyrics != null) { + return remoteLyrics + } else if (localLyrics != null) { + return localLyrics + } + + } + + return LYRICS_NOT_FOUND + } + + /** + * Lookup lyrics from remote providers + */ + private suspend fun getRemoteLyrics(mediaMetadata: MediaMetadata): String? { lyricsProviders.forEach { provider -> if (provider.isEnabled(context)) { provider.getLyrics( @@ -33,7 +68,30 @@ class LyricsHelper @Inject constructor( } } } - return LYRICS_NOT_FOUND + return null + } + + /** + * Lookup lyrics from local disk (.lrc) file + */ + private suspend fun getLocalLyrics(mediaMetadata: MediaMetadata): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + throw Exception("Local lyrics are not supported below SDK 26 (Oreo)") + } + if (LocalLyricsProvider.isEnabled(context)) { + LocalLyricsProvider.getLyrics( + mediaMetadata.id, + "" + mediaMetadata.localPath, // title used as path + mediaMetadata.artists.joinToString { it.name }, + mediaMetadata.duration + ).onSuccess { lyrics -> + return lyrics + }.onFailure { + reportException(it) + } + } + + return null } suspend fun getAllLyrics( diff --git a/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsUtils.kt b/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsUtils.kt index e9e077434..f503866f2 100644 --- a/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsUtils.kt +++ b/app/src/main/java/com/dd3boh/outertune/lyrics/LyricsUtils.kt @@ -1,39 +1,127 @@ package com.dd3boh.outertune.lyrics -import android.text.format.DateUtils import com.dd3boh.outertune.ui.component.animateScrollDuration +import kotlin.math.pow -@Suppress("RegExpRedundantEscape") object LyricsUtils { - val LINE_REGEX = "((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\])+)(.+)".toRegex() - val TIME_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]".toRegex() - - fun parseLyrics(lyrics: String): List = - lyrics.lines() - .flatMap { line -> - parseLine(line).orEmpty() - }.sorted() - - private fun parseLine(line: String): List? { - if (line.isEmpty()) { - return null - } - val matchResult = LINE_REGEX.matchEntire(line.trim()) ?: return null - val times = matchResult.groupValues[1] - val text = matchResult.groupValues[3] - val timeMatchResults = TIME_REGEX.findAll(times) - - return timeMatchResults.map { timeMatchResult -> - val min = timeMatchResult.groupValues[1].toLong() - val sec = timeMatchResult.groupValues[2].toLong() - val milString = timeMatchResult.groupValues[3] - var mil = milString.toLong() - if (milString.length == 2) { - mil *= 10 + private val timeMarksRegex = "\\[(\\d{2}:\\d{2})([.:]\\d+)?]".toRegex() + + + /** + * Give lyrics in LRC format, parse and return a list of LyricEntry. + * + * The following implementation is imported from Gramophone (https://github.com/AkaneTan/Gramophone) + * and has been adapted for OverTune (mostly variable renaming). + * Note: OverTube does not support lyric translations. + * + * + * Formats we have to consider in this method are: + * - Simple LRC files (ref Wikipedia) ex: [00:11.22] hello i am lyric + * - "compressed LRC" with >1 tag for repeating line ex: [00:11.22][00:15.33] hello i am lyric + * - Invalid LRC with all-zero tags [00:00.00] hello i am lyric + * - Lyrics that aren't synced and have no tags at all + * - Translations, type 1 (ex: pasting first japanese and then english lrc file into one file) + * - Translations, type 2 (ex: translated line directly under previous non-translated line) + * - The timestamps can variate in the following ways: [00:11] [00:11:22] [00:11.22] [00:11.222] [00:11:222] + * + * Multiline format: + * - This technically isn't part of any listed guidelines, however is allows for + * reading of otherwise discarded lyrics + * - All the lines between sync point A and B are read as lyric text of A + * + * In the future, we also want to support: + * - Extended LRC (ref Wikipedia) ex: [00:11.22] <00:11.22> hello <00:12.85> i am <00:13.23> lyric + * - Wakaloke gender extension (ref Wikipedia) + * - [offset:] tag in header (ref Wikipedia) + * We completely ignore all ID3 tags from the header as MediaStore is our source of truth. + */ + fun parseLyrics(lyrics: String, trim: Boolean, multilineEnable: Boolean): List { + val list = mutableListOf() + var foundNonNull = false + var lyricsText: StringBuilder? = StringBuilder() + //val measureTime = measureTimeMillis { + // Add all lines found on LRC (probably will be unordered because of "compression" or translation type) + lyrics.lines().forEach { line -> + timeMarksRegex.findAll(line).let { sequence -> + if (sequence.count() == 0) { + return@let + } + var lyricLine: String + sequence.forEach { match -> + val firstSync = match.groupValues.subList(1, match.groupValues.size) + .joinToString("") + + val ts = parseTime(firstSync) + if (!foundNonNull && ts > 0) { + foundNonNull = true + lyricsText = null + } + + if (multilineEnable) { + val startIndex = lyrics.indexOf(line) + firstSync.length + 1 + var endIndex = lyrics.length // default to end + var nextSync = "" + + // track next sync point if found + if (timeMarksRegex.find(lyrics, startIndex)?.value != null) { + nextSync = timeMarksRegex.find(lyrics, startIndex)?.value!! + endIndex = lyrics.indexOf(nextSync) - 1 // delete \n at end + } + + // read as single line *IF* this is a single line lyric + if (nextSync == "[$firstSync]") { + lyricLine = line.substring(sequence.last().range.last + 1) + .let { if (trim) it.trim() else it } + } else { + lyricLine = lyrics.substring(startIndex + 1, endIndex) + .let { if (trim) it.trim() else it } + } + } else { + lyricLine = line.substring(sequence.last().range.last + 1) + .let { if (trim) it.trim() else it } + } + + lyricsText?.append(lyricLine + "\n") + list.add(LyricsEntry(ts, lyricLine)) + } } - val time = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil - LyricsEntry(time, text) - }.toList() + } + // Sort and mark as translations all found duplicated timestamps (usually one) + list.sortBy { it.time } + var previousTs = -1L + list.forEach { + it.isTranslation = (it.time == previousTs) + previousTs = it.time + } + + if (list.isEmpty() && lyrics.isNotEmpty()) { + list.add(LyricsEntry(1, lyrics, false)) + } else if (!foundNonNull) { + list.clear() + list.add(LyricsEntry(1, lyricsText!!.toString(), false)) + } + + return list + } + + /** + * Parse a timestamp in string format (ex: [mm:ss.ms]) into a Long value + * + * The following implementation is imported from Gramophone (https://github.com/AkaneTan/Gramophone) + */ + private fun parseTime(timeString: String): Long { + val timeRegex = "(\\d{2}):(\\d{2})[.:](\\d+)".toRegex() + val matchResult = timeRegex.find(timeString) + + val minutes = matchResult?.groupValues?.get(1)?.toLongOrNull() ?: 0 + val seconds = matchResult?.groupValues?.get(2)?.toLongOrNull() ?: 0 + val millisecondsString = matchResult?.groupValues?.get(3) + // if one specifies micro/pico/nano/whatever seconds for some insane reason, + // scrap the extra information + val milliseconds = (millisecondsString?.substring(0, millisecondsString.length.coerceAtMost(3) + )?.toLongOrNull() ?: 0) * 10f.pow(3 - (millisecondsString?.length ?: 0)).toLong() + + return minutes * 60000 + seconds * 1000 + milliseconds } fun findCurrentLineIndex(lines: List, position: Long): Int { diff --git a/app/src/main/java/com/dd3boh/outertune/models/MediaMetadata.kt b/app/src/main/java/com/dd3boh/outertune/models/MediaMetadata.kt index 69b345fad..bcca6e468 100644 --- a/app/src/main/java/com/dd3boh/outertune/models/MediaMetadata.kt +++ b/app/src/main/java/com/dd3boh/outertune/models/MediaMetadata.kt @@ -5,6 +5,7 @@ import com.zionhuang.innertube.models.SongItem import com.dd3boh.outertune.db.entities.* import com.dd3boh.outertune.ui.utils.resize import java.io.Serializable +import java.time.LocalDateTime @Immutable data class MediaMetadata( @@ -15,10 +16,13 @@ data class MediaMetadata( val thumbnailUrl: String? = null, val album: Album? = null, val setVideoId: String? = null, + val isLocal: Boolean = false, + val localPath: String? = null, ) : Serializable { data class Artist( val id: String?, val name: String, + val isLocal: Boolean = false, ) : Serializable data class Album( @@ -32,7 +36,10 @@ data class MediaMetadata( duration = duration, thumbnailUrl = thumbnailUrl, albumId = album?.id, - albumName = album?.title + albumName = album?.title, + isLocal = isLocal, + inLibrary = if (isLocal) LocalDateTime.now() else null, + localPath = localPath ) } @@ -42,7 +49,8 @@ fun Song.toMediaMetadata() = MediaMetadata( artists = artists.map { MediaMetadata.Artist( id = it.id, - name = it.name + name = it.name, + isLocal = it.isLocal ) }, duration = song.duration, @@ -57,7 +65,9 @@ fun Song.toMediaMetadata() = MediaMetadata( id = albumId, title = song.albumName.orEmpty() ) - } + }, + isLocal = song.isLocal, + localPath = song.localPath ) fun SongItem.toMediaMetadata() = MediaMetadata( diff --git a/app/src/main/java/com/dd3boh/outertune/playback/MusicService.kt b/app/src/main/java/com/dd3boh/outertune/playback/MusicService.kt index 8966434af..0c97ebfc1 100644 --- a/app/src/main/java/com/dd3boh/outertune/playback/MusicService.kt +++ b/app/src/main/java/com/dd3boh/outertune/playback/MusicService.kt @@ -7,7 +7,9 @@ import android.content.Intent import android.database.SQLException import android.media.audiofx.AudioEffect import android.net.ConnectivityManager +import android.net.Uri import android.os.Binder +import android.util.Log import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.datastore.preferences.core.edit @@ -111,6 +113,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -120,6 +123,7 @@ import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import java.io.File import java.io.ObjectInputStream import java.io.ObjectOutputStream import java.net.ConnectException @@ -194,7 +198,7 @@ class MusicService : MediaLibraryService(), } ) player = ExoPlayer.Builder(this) - .setMediaSourceFactory(createMediaSourceFactory()) + .setMediaSourceFactory(DefaultMediaSourceFactory(createDataSourceFactory())) .setRenderersFactory(createRenderersFactory()) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_NETWORK) @@ -595,6 +599,16 @@ class MusicService : MediaLibraryService(), return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> val mediaId = dataSpec.key ?: error("No media id") + // find a better way to detect local files later... + if (mediaId.startsWith("LA")) { + val songPath = runBlocking(Dispatchers.IO) { + database.song(mediaId).firstOrNull()?.song?.localPath + } + Log.d("WTF", "Looking for local file: " + songPath) + + return@Factory dataSpec.withUri(Uri.fromFile(File(songPath))) + } + if (downloadCache.isCached(mediaId, dataSpec.position, if (dataSpec.length >= 0) dataSpec.length else 1) || playerCache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH) ) { @@ -607,6 +621,7 @@ class MusicService : MediaLibraryService(), return@Factory dataSpec.withUri(it.first.toUri()) } + Log.d("WTF", "Media ID for current song: " + mediaId) // Check whether format exists so that users from older version can view format details // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() } @@ -669,14 +684,6 @@ class MusicService : MediaLibraryService(), } } - private fun createMediaSourceFactory() = - DefaultMediaSourceFactory( - createDataSourceFactory(), - ExtractorsFactory { - arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) - } - ) - private fun createRenderersFactory() = object : DefaultRenderersFactory(this) { override fun buildAudioSink( context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean diff --git a/app/src/main/java/com/dd3boh/outertune/ui/component/AsyncImageLocal.kt b/app/src/main/java/com/dd3boh/outertune/ui/component/AsyncImageLocal.kt new file mode 100644 index 000000000..9bed18e54 --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/ui/component/AsyncImageLocal.kt @@ -0,0 +1,73 @@ +package com.dd3boh.outertune.ui.component + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch + +const val MAX_IMAGE_JOBS = 8 +@OptIn(ExperimentalCoroutinesApi::class) +val imageSession = Dispatchers.IO.limitedParallelism(MAX_IMAGE_JOBS) + +/** + * Non-blocking image + */ +@Composable +fun AsyncLocalImage( + image: () -> Bitmap?, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + var imageBitmapState by remember { mutableStateOf(null) } + LaunchedEffect(image) { + CoroutineScope(imageSession).launch { + try { + imageBitmapState = image.invoke()?.asImageBitmap() + } catch (e: Exception) { +// e.printStackTrace() + // this probably won't be an issue when debugging... + // I'd like to add that this WAS a problem when debugging. + } + } + } + + imageBitmapState.let { imageBitmap -> + if (imageBitmap == null) { + Icon( + Icons.Rounded.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(16.dp)) + .aspectRatio(ratio = 1f) + ) + } else { + Image( + bitmap = imageBitmap, + contentDescription = contentDescription, + modifier = modifier.fillMaxSize(), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dd3boh/outertune/ui/component/Items.kt b/app/src/main/java/com/dd3boh/outertune/ui/component/Items.kt index 197f6f665..19978598f 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/component/Items.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/component/Items.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -23,11 +22,17 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width + import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.Done import androidx.compose.material.icons.rounded.Explicit import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.FolderCopy +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.OfflinePin import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -60,15 +65,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.zIndex import androidx.core.graphics.drawable.toBitmapOrNull import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING import androidx.media3.exoplayer.offline.Download.STATE_QUEUED +import androidx.navigation.NavController import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.request.ImageRequest @@ -80,7 +86,6 @@ import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.YTItem import com.dd3boh.outertune.LocalDatabase import com.dd3boh.outertune.LocalDownloadUtil -import com.dd3boh.outertune.LocalPlayerAwareWindowInsets import com.dd3boh.outertune.LocalPlayerConnection import com.dd3boh.outertune.R import com.dd3boh.outertune.constants.GridThumbnailHeight @@ -95,8 +100,10 @@ import com.dd3boh.outertune.db.entities.Song import com.dd3boh.outertune.extensions.toMediaItem import com.dd3boh.outertune.models.MediaMetadata import com.dd3boh.outertune.playback.queues.ListQueue -import com.dd3boh.outertune.ui.screens.MoodAndGenresButtonHeight +import com.dd3boh.outertune.ui.menu.FolderMenu import com.dd3boh.outertune.ui.theme.extractThemeColor +import com.dd3boh.outertune.ui.utils.DirectoryTree +import com.dd3boh.outertune.ui.utils.getLocalThumbnail import com.dd3boh.outertune.utils.joinByBullet import com.dd3boh.outertune.utils.makeTimeString import com.dd3boh.outertune.utils.reportException @@ -166,12 +173,24 @@ fun ListItem( badges: @Composable RowScope.() -> Unit = {}, thumbnailContent: @Composable () -> Unit, trailingContent: @Composable RowScope.() -> Unit = {}, - isActive: Boolean = false + isActive: Boolean = false, + isLocalSong: Boolean? = null, ) = ListItem( title = title, subtitle = { badges() + // local song indicator + if (isLocalSong == true) { + Icon( + Icons.Rounded.FolderCopy, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (!subtitle.isNullOrEmpty()) { Text( text = subtitle, @@ -256,6 +275,7 @@ fun SongListItem( showLikedIcon: Boolean = true, showInLibraryIcon: Boolean = false, showDownloadIcon: Boolean = true, + isSelected: Boolean = false, badges: @Composable RowScope.() -> Unit = { if (showLikedIcon && song.song.liked) { Icon( @@ -297,6 +317,17 @@ fun SongListItem( else -> {} } } + + // local song indicator + if (song.song.isLocal) { + Icon( + Icons.Rounded.FolderCopy, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } }, isActive: Boolean = false, isPlaying: Boolean = false, @@ -319,19 +350,56 @@ fun SongListItem( enter = fadeIn() + expandIn(expandFrom = Alignment.Center), exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() ) { - Text( - text = albumIndex.toString(), - style = MaterialTheme.typography.labelLarge - ) + + if (isSelected) { + Icon( + Icons.Rounded.Done, + modifier = Modifier.align(Alignment.Center), + contentDescription = null + ) + }else { + Text( + text = albumIndex.toString(), + style = MaterialTheme.typography.labelLarge + ) + } } } else { - AsyncImage( - model = song.song.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) + if (isSelected) { + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(1000f) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(Color.Black.copy(alpha = 0.5f)) + ) { + Icon( + Icons.Rounded.Done, + modifier = Modifier.align(Alignment.Center), + contentDescription = null + ) + } + } + + if (song.song.isLocal) { + // local thumbnail arts + AsyncLocalImage( + image = { getLocalThumbnail(song.song.localPath, true) }, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } else { + // YTM thumbnail arts + AsyncImage( + model = song.song.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } } PlayingIndicatorBox( @@ -353,6 +421,74 @@ fun SongListItem( modifier = modifier, isActive = isActive ) +@Composable +fun SongFolderItem( + folderTitle: String, + modifier: Modifier = Modifier, +) = ListItem(title = folderTitle, thumbnailContent = { + Icon( + Icons.Rounded.Folder, + contentDescription = null, + modifier = modifier.size(48.dp) + ) + }, + modifier = modifier +) + +@Composable +fun SongFolderItem( + folderTitle: String, + subtitle: String?, + modifier: Modifier = Modifier, +) = ListItem(title = folderTitle, + subtitle = subtitle, + thumbnailContent = { + Icon( + Icons.Rounded.Folder, + contentDescription = null, + modifier = modifier.size(48.dp) + ) + }, + modifier = modifier +) + +@Composable +fun SongFolderItem( + folder: DirectoryTree, + modifier: Modifier = Modifier, + folderTitle: String? = null, + menuState: MenuState, + navController: NavController, + subtitle: String +) = ListItem(title = folderTitle ?: folder.currentDir, + subtitle = subtitle, + thumbnailContent = { + Icon( + Icons.Rounded.Folder, + contentDescription = null, + modifier = modifier.size(48.dp) + ) +}, + trailingContent = { + androidx.compose.material3.IconButton( + onClick = { + menuState.show { + FolderMenu( + folder = folder, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null + ) + } + }, + modifier = modifier +) @Composable fun ArtistListItem( @@ -369,6 +505,18 @@ fun ArtistListItem( .padding(end = 2.dp) ) } + + // assume if they have a non local artist ID, they are not local + if (artist.artist.isLocalArtist) { + Icon( + Icons.Rounded.CloudOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } }, trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( @@ -403,6 +551,18 @@ fun ArtistGridItem( .padding(end = 2.dp) ) } + + // assume if they have a non local artist ID, they are not local + if (artist.artist.isLocalArtist) { + Icon( + Icons.Rounded.CloudOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } }, fillMaxWidth: Boolean = false, ) = GridItem( @@ -903,13 +1063,25 @@ fun MediaMetadataListItem( makeTimeString(mediaMetadata.duration * 1000L) ), thumbnailContent = { - AsyncImage( - model = mediaMetadata.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .size(ListThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) + if (mediaMetadata.isLocal) { + // local thumbnail arts + AsyncLocalImage( + image = { getLocalThumbnail(mediaMetadata.localPath, true) }, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } else { + // YTM thumbnail arts + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } PlayingIndicatorBox( isActive = isActive, @@ -924,7 +1096,8 @@ fun MediaMetadataListItem( }, trailingContent = trailingContent, modifier = modifier, - isActive = isActive + isActive = isActive, + isLocalSong = mediaMetadata.isLocal ) @Composable @@ -1353,3 +1526,4 @@ fun YouTubeCardItem( } } } + diff --git a/app/src/main/java/com/dd3boh/outertune/ui/component/Lyrics.kt b/app/src/main/java/com/dd3boh/outertune/ui/component/Lyrics.kt index 784369450..09c6ff5e0 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/component/Lyrics.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/component/Lyrics.kt @@ -51,7 +51,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dd3boh.outertune.LocalPlayerConnection import com.dd3boh.outertune.R +import com.dd3boh.outertune.constants.LyricTrimKey import com.dd3boh.outertune.constants.LyricsTextPositionKey +import com.dd3boh.outertune.constants.MultilineLrcKey import com.dd3boh.outertune.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND import com.dd3boh.outertune.lyrics.LyricsEntry import com.dd3boh.outertune.lyrics.LyricsEntry.Companion.HEAD_LYRICS_ENTRY @@ -63,6 +65,7 @@ import com.dd3boh.outertune.ui.menu.LyricsMenu import com.dd3boh.outertune.ui.screens.settings.LyricsPosition import com.dd3boh.outertune.ui.utils.fadingEdge import com.dd3boh.outertune.utils.rememberEnumPreference +import com.dd3boh.outertune.utils.rememberPreference import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlin.time.Duration.Companion.seconds @@ -80,11 +83,14 @@ fun Lyrics( val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val lyricsEntity by playerConnection.currentLyrics.collectAsState(initial = null) - val lyrics = remember(lyricsEntity) { lyricsEntity?.lyrics } + val lyrics = remember(lyricsEntity) { lyricsEntity?.lyrics?.trim() } + val multilineLrc = rememberPreference(MultilineLrcKey, defaultValue = true) + val lyricTrim = rememberPreference(LyricTrimKey, defaultValue = false) val lines = remember(lyrics) { if (lyrics == null || lyrics == LYRICS_NOT_FOUND) emptyList() - else if (lyrics.startsWith("[")) listOf(HEAD_LYRICS_ENTRY) + parseLyrics(lyrics) + else if (lyrics.startsWith("[")) listOf(HEAD_LYRICS_ENTRY) + + parseLyrics(lyrics, lyricTrim.value, multilineLrc.value) else lyrics.lines().mapIndexed { index, line -> LyricsEntry(index * 100L, line) } } val isSynced = remember(lyrics) { diff --git a/app/src/main/java/com/dd3boh/outertune/ui/menu/AddToPlaylistDialog.kt b/app/src/main/java/com/dd3boh/outertune/ui/menu/AddToPlaylistDialog.kt index 5f0e38b4d..2f445449f 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/menu/AddToPlaylistDialog.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/menu/AddToPlaylistDialog.kt @@ -2,6 +2,7 @@ package com.dd3boh.outertune.ui.menu import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -21,6 +22,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import com.dd3boh.outertune.LocalDatabase import com.dd3boh.outertune.R @@ -87,6 +91,14 @@ fun AddToPlaylistDialog( } ) } + + item { + Text( + text = "Note: Adding local songs to synced/remote playlists is unsupported. Any other combination is valid.", + fontSize = TextUnit(12F, TextUnitType.Sp), + modifier = Modifier.padding(horizontal = 20.dp) + ) + } } } diff --git a/app/src/main/java/com/dd3boh/outertune/ui/menu/AlbumMenu.kt b/app/src/main/java/com/dd3boh/outertune/ui/menu/AlbumMenu.kt index 61ac20e5c..572113ddd 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/menu/AlbumMenu.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/menu/AlbumMenu.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.rounded.PlaylistAdd import androidx.compose.material.icons.rounded.PlaylistPlay import androidx.compose.material.icons.rounded.QueueMusic import androidx.compose.material.icons.rounded.QueuePlayNext +import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.Divider import androidx.compose.material3.HorizontalDivider @@ -81,6 +82,7 @@ fun AlbumMenu( originalAlbum: Album, navController: NavController, onDismiss: () -> Unit, + selectAction: () -> Unit = {}, ) { val context = LocalContext.current val database = LocalDatabase.current @@ -304,5 +306,13 @@ fun AlbumMenu( } context.startActivity(Intent.createChooser(intent, null)) } + + GridMenuItem( + icon = Icons.Rounded.SelectAll, + title = R.string.select + ) { + onDismiss() + selectAction() + } } } diff --git a/app/src/main/java/com/dd3boh/outertune/ui/menu/FolderMenu.kt b/app/src/main/java/com/dd3boh/outertune/ui/menu/FolderMenu.kt new file mode 100644 index 000000000..cc9bba444 --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/ui/menu/FolderMenu.kt @@ -0,0 +1,111 @@ +package com.dd3boh.outertune.ui.menu + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.PlaylistAdd +import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.dd3boh.outertune.LocalDatabase +import com.dd3boh.outertune.LocalPlayerConnection +import com.dd3boh.outertune.R +import com.dd3boh.outertune.db.entities.Event +import com.dd3boh.outertune.db.entities.PlaylistSongMap +import com.dd3boh.outertune.extensions.toMediaItem +import com.dd3boh.outertune.ui.component.GridMenu +import com.dd3boh.outertune.ui.component.GridMenuItem +import com.dd3boh.outertune.ui.component.SongFolderItem +import com.dd3boh.outertune.ui.utils.DirectoryTree + +@Composable +fun FolderMenu( + folder: DirectoryTree, + event: Event? = null, + navController: NavController, + onDismiss: () -> Unit, +) { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + + val allFolderSongs = folder.toList() + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { playlist -> + // shove all folder songs into the playlist + database.query { + allFolderSongs.forEach { + insert( + PlaylistSongMap( + songId = it.song.id, + playlistId = playlist.id, + position = playlist.songCount + ) + ) + } + + } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + // folder info + SongFolderItem( + folderTitle = folder.currentDir, + modifier = Modifier, + subtitle = folder.parent, + ) + + HorizontalDivider() + + // options + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = Icons.AutoMirrored.Rounded.PlaylistPlay, + title = R.string.play_next + ) { + onDismiss() + allFolderSongs.forEach { + playerConnection.playNext(it.toMediaItem()) + } + } + GridMenuItem( + icon = Icons.AutoMirrored.Rounded.QueueMusic, + title = R.string.add_to_queue + ) { + onDismiss() + allFolderSongs.forEach { + playerConnection.addToQueue((it.toMediaItem())) + } + } + GridMenuItem( + icon = Icons.AutoMirrored.Rounded.PlaylistAdd, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + } +} diff --git a/app/src/main/java/com/dd3boh/outertune/ui/menu/PlayerMenu.kt b/app/src/main/java/com/dd3boh/outertune/ui/menu/PlayerMenu.kt index 03b8abad5..44bc3972b 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/menu/PlayerMenu.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/menu/PlayerMenu.kt @@ -206,45 +206,47 @@ fun PlayerMenu( bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() ) ) { - GridMenuItem( - icon = Icons.Rounded.Radio, - title = R.string.start_radio - ) { - playerConnection.service.startRadioSeamlessly() - onDismiss() - } + if (mediaMetadata.isLocal != true) + GridMenuItem( + icon = Icons.Rounded.Radio, + title = R.string.start_radio + ) { + playerConnection.service.startRadioSeamlessly() + onDismiss() + } GridMenuItem( icon = Icons.AutoMirrored.Rounded.PlaylistAdd, title = R.string.add_to_playlist ) { showChoosePlaylistDialog = true } - DownloadGridMenu( - state = download?.state, - onDownload = { - database.transaction { - insert(mediaMetadata) + if (mediaMetadata.isLocal != true) + DownloadGridMenu( + state = download?.state, + onDownload = { + database.transaction { + insert(mediaMetadata) + } + val downloadRequest = DownloadRequest.Builder(mediaMetadata.id, mediaMetadata.id.toUri()) + .setCustomCacheKey(mediaMetadata.id) + .setData(mediaMetadata.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + mediaMetadata.id, + false + ) } - val downloadRequest = DownloadRequest.Builder(mediaMetadata.id, mediaMetadata.id.toUri()) - .setCustomCacheKey(mediaMetadata.id) - .setData(mediaMetadata.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false - ) - }, - onRemoveDownload = { - DownloadService.sendRemoveDownload( - context, - ExoDownloadService::class.java, - mediaMetadata.id, - false - ) - } - ) + ) GridMenuItem( icon = R.drawable.artist, title = R.string.view_artist @@ -267,18 +269,20 @@ fun PlayerMenu( onDismiss() } } - GridMenuItem( - icon = Icons.Rounded.Share, - title = R.string.share - ) { - val intent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaMetadata.id}") + + if (mediaMetadata.isLocal != true) + GridMenuItem( + icon = Icons.Rounded.Share, + title = R.string.share + ) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaMetadata.id}") + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() } - context.startActivity(Intent.createChooser(intent, null)) - onDismiss() - } GridMenuItem( icon = Icons.Rounded.Info, title = R.string.details diff --git a/app/src/main/java/com/dd3boh/outertune/ui/menu/SelectionSongsMenu.kt b/app/src/main/java/com/dd3boh/outertune/ui/menu/SelectionSongsMenu.kt new file mode 100644 index 000000000..79b7e3c75 --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/ui/menu/SelectionSongsMenu.kt @@ -0,0 +1,435 @@ +package com.dd3boh.outertune.ui.menu + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.PlaylistAdd +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import com.dd3boh.outertune.LocalDatabase +import com.dd3boh.outertune.LocalDownloadUtil +import com.dd3boh.outertune.LocalPlayerConnection +import com.dd3boh.outertune.db.entities.PlaylistSongMap +import com.dd3boh.outertune.db.entities.Song +import com.dd3boh.outertune.ui.component.DefaultDialog +import com.dd3boh.outertune.R +import com.dd3boh.outertune.extensions.toMediaItem +import com.dd3boh.outertune.models.MediaMetadata +import com.dd3boh.outertune.playback.ExoDownloadService +import com.dd3boh.outertune.playback.queues.ListQueue +import com.dd3boh.outertune.ui.component.DownloadGridMenu +import com.dd3boh.outertune.ui.component.GridMenu +import com.dd3boh.outertune.ui.component.GridMenuItem + + +@Composable +fun SelectionSongMenu( + songSelection: List, + onDismiss: () -> Unit, + clearAction: () -> Unit, + songPosition: List? = emptyList(), +){ + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songSelection) { + if (songSelection.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songSelection.all { downloads[it.id]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songSelection.all { + downloads[it.id]?.state == Download.STATE_QUEUED + || downloads[it.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.id]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { playlist -> + database.query { + songSelection.forEach { song -> + insert( + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = playlist.songCount + ) + ) + } + } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource(R.string.remove_download_playlist_confirm, "selection"), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { + showRemoveDownloadDialog = false + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + songSelection.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + GridMenu ( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ){ + GridMenuItem( + icon = R.drawable.play, + title = R.string.play + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = "Selection", + items = songSelection.map { it.toMediaItem() } + ) + ) + clearAction() + } + + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = "Selection", + items = songSelection.shuffled().map { it.toMediaItem() } + ) + ) + clearAction() + } + + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + onDismiss() + playerConnection.addToQueue(songSelection.map { it.toMediaItem() }) + clearAction() + } + + GridMenuItem( + icon = Icons.AutoMirrored.Rounded.PlaylistAdd, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + + DownloadGridMenu( + state = downloadState, + onDownload = { + songSelection.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + showRemoveDownloadDialog = true + } + ) + + GridMenuItem( + icon = if (songSelection.all{it.song.liked}) R.drawable.favorite else R.drawable.favorite_border, + title = R.string.like_all + ) { + val allLiked = songSelection.all { + it.song.liked + } + onDismiss() + database.query { + songSelection.forEach { song -> + if ((!allLiked && !song.song.liked) || allLiked) + update(song.song.toggleLike()) + } + } + } + + if (songPosition != null) { + GridMenuItem( + icon = Icons.Rounded.Delete, + title = R.string.delete + ) { + onDismiss() + var i = 0 + database.query { + songPosition.forEach {cur -> + move(cur.playlistId, cur.position - i, Int.MAX_VALUE) + delete(cur.copy(position = Int.MAX_VALUE)) + i++ + } + } + clearAction() + } + } + } +} + +@Composable +fun SelectionMediaMetadataMenu( + songSelection: List, + currentItems: List, + onDismiss: () -> Unit, + clearAction: () -> Unit, +){ + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songSelection) { + if (songSelection.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songSelection.all { downloads[it.id]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songSelection.all { + downloads[it.id]?.state == Download.STATE_QUEUED + || downloads[it.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.id]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { playlist -> + database.query { + songSelection.forEach { song -> + insert( + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = playlist.songCount + ) + ) + } + } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource(R.string.remove_download_playlist_confirm, "selection"), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { + showRemoveDownloadDialog = false + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + songSelection.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + GridMenu ( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ){ + GridMenuItem( + icon = Icons.Rounded.Delete, + title = R.string.delete + ) { + onDismiss() + var i = 0 + currentItems.forEach { cur -> + playerConnection.player.removeMediaItem(cur.firstPeriodIndex - i) + i++ + } + clearAction() + } + + GridMenuItem( + icon = R.drawable.play, + title = R.string.play + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = "Selection", + items = songSelection.map { it.toMediaItem() } + ) + ) + clearAction() + } + + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = "Selection", + items = songSelection.shuffled().map { it.toMediaItem() } + ) + ) + clearAction() + } + + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + onDismiss() + playerConnection.addToQueue(songSelection.map { it.toMediaItem() }) + clearAction() + } + + GridMenuItem( + icon = Icons.AutoMirrored.Rounded.PlaylistAdd, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + + DownloadGridMenu( + state = downloadState, + onDownload = { + songSelection.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + showRemoveDownloadDialog = true + } + ) + } +} diff --git a/app/src/main/java/com/dd3boh/outertune/ui/menu/SongMenu.kt b/app/src/main/java/com/dd3boh/outertune/ui/menu/SongMenu.kt index a7940e04a..688ffda2f 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/menu/SongMenu.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/menu/SongMenu.kt @@ -223,13 +223,14 @@ fun SongMenu( bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() ) ) { - GridMenuItem( - icon = Icons.Rounded.Radio, - title = R.string.start_radio - ) { - onDismiss() - playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata(), playlistId = WatchEndpoint(videoId = song.id).playlistId)) - } + if (!song.song.isLocal) + GridMenuItem( + icon = Icons.Rounded.Radio, + title = R.string.start_radio + ) { + onDismiss() + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata(), playlistId = WatchEndpoint(videoId = song.id).playlistId)) + } GridMenuItem( icon = Icons.AutoMirrored.Rounded.PlaylistPlay, title = R.string.play_next @@ -280,29 +281,32 @@ fun SongMenu( } } - DownloadGridMenu( - state = download?.state, - onDownload = { - val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) - .setCustomCacheKey(song.id) - .setData(song.song.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false - ) - }, - onRemoveDownload = { - DownloadService.sendRemoveDownload( - context, - ExoDownloadService::class.java, - song.id, - false - ) - } - ) + if (!song.song.isLocal) + DownloadGridMenu( + state = download?.state, + onDownload = { + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + ) + + GridMenuItem( icon = R.drawable.artist, title = R.string.view_artist @@ -314,7 +318,7 @@ fun SongMenu( showSelectArtistDialog = true } } - if (song.song.albumId != null) { + if (song.song.albumId != null && !song.song.isLocal) { GridMenuItem( icon = Icons.Rounded.Album, title = R.string.view_album @@ -323,18 +327,19 @@ fun SongMenu( navController.navigate("album/${song.song.albumId}") } } - GridMenuItem( - icon = Icons.Rounded.Share, - title = R.string.share - ) { - onDismiss() - val intent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${song.id}") + if (!song.song.isLocal) + GridMenuItem( + icon = Icons.Rounded.Share, + title = R.string.share + ) { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${song.id}") + } + context.startActivity(Intent.createChooser(intent, null)) } - context.startActivity(Intent.createChooser(intent, null)) - } if (event != null) { GridMenuItem( icon = Icons.Rounded.Delete, diff --git a/app/src/main/java/com/dd3boh/outertune/ui/player/MiniPlayer.kt b/app/src/main/java/com/dd3boh/outertune/ui/player/MiniPlayer.kt index ca60220b5..c50028798 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/player/MiniPlayer.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/player/MiniPlayer.kt @@ -48,6 +48,8 @@ import com.dd3boh.outertune.constants.MiniPlayerHeight import com.dd3boh.outertune.constants.ThumbnailCornerRadius import com.dd3boh.outertune.extensions.togglePlayPause import com.dd3boh.outertune.models.MediaMetadata +import com.dd3boh.outertune.ui.component.AsyncLocalImage +import com.dd3boh.outertune.ui.utils.getLocalThumbnail @Composable fun MiniPlayer( @@ -131,13 +133,26 @@ fun MiniMediaInfo( modifier = modifier ) { Box(modifier = Modifier.padding(6.dp)) { - AsyncImage( - model = mediaMetadata.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) + if (mediaMetadata.isLocal) { + // local thumbnail arts + AsyncLocalImage( + image = { getLocalThumbnail(mediaMetadata.localPath, true) }, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } else { + // YTM thumbnail arts + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } + androidx.compose.animation.AnimatedVisibility( visible = error != null, enter = fadeIn(), diff --git a/app/src/main/java/com/dd3boh/outertune/ui/player/Player.kt b/app/src/main/java/com/dd3boh/outertune/ui/player/Player.kt index 87501d2e0..a5ea5f84c 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/player/Player.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/player/Player.kt @@ -4,8 +4,10 @@ import android.content.res.Configuration import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -82,6 +84,7 @@ import com.dd3boh.outertune.utils.makeTimeString import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +@OptIn(ExperimentalFoundationApi::class) @Composable fun BottomSheetPlayer( state: BottomSheetState, @@ -154,6 +157,7 @@ fun BottomSheetPlayer( overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(horizontal = PlayerHorizontalPadding) + .basicMarquee() .clickable(enabled = mediaMetadata.album != null) { navController.navigate("album/${mediaMetadata.album!!.id}") state.collapseSoft() diff --git a/app/src/main/java/com/dd3boh/outertune/ui/player/Queue.kt b/app/src/main/java/com/dd3boh/outertune/ui/player/Queue.kt index acb4ec4cc..b3473675b 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/player/Queue.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/player/Queue.kt @@ -1,11 +1,14 @@ package com.dd3boh.outertune.ui.player +import android.annotation.SuppressLint import android.text.format.Formatter import android.widget.Toast import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,11 +35,15 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.Bedtime +import androidx.compose.material.icons.rounded.CheckBox +import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank +import androidx.compose.material.icons.rounded.Deselect import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Lyrics import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.QueueMusic import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.Timer @@ -44,6 +51,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.OutlinedButton @@ -91,11 +99,13 @@ import com.dd3boh.outertune.constants.ShowLyricsKey import com.dd3boh.outertune.extensions.metadata import com.dd3boh.outertune.extensions.move import com.dd3boh.outertune.extensions.togglePlayPause +import com.dd3boh.outertune.models.MediaMetadata import com.dd3boh.outertune.ui.component.BottomSheet import com.dd3boh.outertune.ui.component.BottomSheetState import com.dd3boh.outertune.ui.component.LocalMenuState import com.dd3boh.outertune.ui.component.MediaMetadataListItem import com.dd3boh.outertune.ui.menu.PlayerMenu +import com.dd3boh.outertune.ui.menu.SelectionMediaMetadataMenu import com.dd3boh.outertune.utils.makeTimeString import com.dd3boh.outertune.utils.rememberPreference import kotlinx.coroutines.Dispatchers @@ -109,7 +119,8 @@ import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.reorderable import kotlin.math.roundToInt -@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) +@SuppressLint("UnrememberedMutableState") +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun Queue( state: BottomSheetState, @@ -209,6 +220,9 @@ fun Queue( ) } + val selectedSongs: MutableList = mutableStateListOf() + val selectedItems: MutableList = mutableStateListOf() + var showDetailsDialog by rememberSaveable { mutableStateOf(false) } @@ -410,10 +424,12 @@ fun Queue( playerConnection.player.removeMediaItem(currentItem.firstPeriodIndex) return@rememberSwipeToDismissBoxState true } + SwipeToDismissBoxValue.EndToStart -> { playerConnection.player.removeMediaItem(currentItem.firstPeriodIndex) return@rememberSwipeToDismissBoxState true } + SwipeToDismissBoxValue.Settled -> { return@rememberSwipeToDismissBoxState false } @@ -424,36 +440,86 @@ fun Queue( state = dismissState, backgroundContent = {}, content = { - MediaMetadataListItem( - mediaMetadata = window.mediaItem.metadata!!, - isActive = index == currentWindowIndex, - isPlaying = isPlaying, - trailingContent = { - IconButton( - onClick = { }, - modifier = Modifier - .detectReorder(reorderableState) - ) { - Icon( - imageVector = Icons.Rounded.DragHandle, - contentDescription = null - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - coroutineScope.launch(Dispatchers.Main) { - if (index == currentWindowIndex) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.player.seekToDefaultPosition(window.firstPeriodIndex) - playerConnection.player.playWhenReady = true - } + Row( + horizontalArrangement = Arrangement.Center + ) { +// IconButton( +// modifier = Modifier +// .align(Alignment.CenterVertically), +// onClick = { +// println(window.mediaItem.metadata!!.title) +// if (window.mediaItem.metadata!! in selectedSongs) { +// selectedSongs.remove(window.mediaItem.metadata!!) +// selectedItems.remove(currentItem) +// } else { +// selectedSongs.add(window.mediaItem.metadata!!) +// selectedItems.add(currentItem) +// } +// } +// ) { +// Icon( +// if (window.mediaItem.metadata!! in selectedSongs) Icons.Rounded.CheckBox else Icons.Rounded.CheckBoxOutlineBlank, +// contentDescription = null, +// tint = LocalContentColor.current +// ) +// } + + MediaMetadataListItem( + mediaMetadata = window.mediaItem.metadata!!, + isActive = index == currentWindowIndex, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { }, + modifier = Modifier + .detectReorder(reorderableState) + ) { + Icon( + imageVector = Icons.Rounded.DragHandle, + contentDescription = null + ) } - } - .detectReorderAfterLongPress(reorderableState) - ) + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (selectedSongs.isEmpty()) { + coroutineScope.launch(Dispatchers.Main) { + if (index == currentWindowIndex) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.player.seekToDefaultPosition(window.firstPeriodIndex) + playerConnection.player.playWhenReady = true + } + } + } else { + if (window.mediaItem.metadata!! in selectedSongs) { + selectedSongs.remove(window.mediaItem.metadata!!) + selectedItems.remove(currentItem) + } else { + selectedSongs.add(window.mediaItem.metadata!!) + selectedItems.add(currentItem) + } + } + }, + onLongClick = { + menuState.show { + PlayerMenu( + mediaMetadata = window.mediaItem.metadata!!, + navController = navController, + playerBottomSheetState = playerBottomSheetState, + onShowDetailsDialog = { + showDetailsDialog = true + }, + onDismiss = menuState::dismiss + ) + } + } + ) + .detectReorderAfterLongPress(reorderableState) + ) + } } ) } @@ -486,6 +552,43 @@ fun Queue( modifier = Modifier.weight(1f) ) + if (selectedSongs.isNotEmpty()) { + + IconButton( + onClick = { + selectedSongs.clear() + selectedItems.clear() + } + ) { + Icon( + Icons.Rounded.Deselect, + contentDescription = null, + tint = LocalContentColor.current + ) + } + IconButton( + onClick = { + menuState.show { + SelectionMediaMetadataMenu( + songSelection = selectedSongs, + onDismiss = menuState::dismiss, + clearAction = { + selectedSongs.clear() + selectedItems.clear() + }, + currentItems = selectedItems + ) + } + } + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null, + tint = LocalContentColor.current + ) + } + } + Column( verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.End diff --git a/app/src/main/java/com/dd3boh/outertune/ui/player/Thumbnail.kt b/app/src/main/java/com/dd3boh/outertune/ui/player/Thumbnail.kt index 7c8c42b54..05c065532 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/player/Thumbnail.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/player/Thumbnail.kt @@ -25,7 +25,9 @@ import com.dd3boh.outertune.LocalPlayerConnection import com.dd3boh.outertune.constants.PlayerHorizontalPadding import com.dd3boh.outertune.constants.ShowLyricsKey import com.dd3boh.outertune.constants.ThumbnailCornerRadius +import com.dd3boh.outertune.ui.component.AsyncLocalImage import com.dd3boh.outertune.ui.component.Lyrics +import com.dd3boh.outertune.ui.utils.getLocalThumbnail import com.dd3boh.outertune.utils.rememberPreference @Composable @@ -35,7 +37,6 @@ fun Thumbnail( ) { val playerConnection = LocalPlayerConnection.current ?: return val currentView = LocalView.current - val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val error by playerConnection.error.collectAsState() @@ -63,24 +64,27 @@ fun Thumbnail( .fillMaxSize() .padding(horizontal = PlayerHorizontalPadding) ) { - AsyncImage( - model = mediaMetadata?.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(ThumbnailCornerRadius * 2)) - .pointerInput(Unit) { - detectTapGestures( - onDoubleTap = { offset -> - if (offset.x < size.width / 2) { - playerConnection.player.seekBack() - } else { - playerConnection.player.seekForward() - } - } - ) - } - ) + if (mediaMetadata?.isLocal == true) { + // local thumbnail arts + mediaMetadata?.let { // required to re render when song changes + AsyncLocalImage( + image = { getLocalThumbnail(it.localPath) }, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } + } else { + // YTM thumbnail arts + AsyncImage( + model = mediaMetadata?.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } } } diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/AlbumScreen.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/AlbumScreen.kt index 818c154ef..62815b614 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/AlbumScreen.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/AlbumScreen.kt @@ -23,9 +23,12 @@ import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Deselect import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.OfflinePin +import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -45,6 +48,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -91,9 +95,11 @@ import com.dd3boh.outertune.ui.component.shimmer.ListItemPlaceHolder import com.dd3boh.outertune.ui.component.shimmer.ShimmerHost import com.dd3boh.outertune.ui.component.shimmer.TextPlaceholder import com.dd3boh.outertune.ui.menu.AlbumMenu +import com.dd3boh.outertune.ui.menu.SelectionSongMenu import com.dd3boh.outertune.ui.menu.SongMenu import com.dd3boh.outertune.ui.utils.backToMain import com.dd3boh.outertune.viewmodels.AlbumViewModel +import com.zionhuang.music.ui.utils.ItemWrapper @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -110,6 +116,10 @@ fun AlbumScreen( val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val albumWithSongs by viewModel.albumWithSongs.collectAsState() + val wrappedSongs = albumWithSongs?.songs?.map { item -> ItemWrapper(item) }?.toMutableList() + var selection by remember { + mutableStateOf(false) + } val snackbarHostState = remember { SnackbarHostState() } @@ -287,7 +297,8 @@ fun AlbumScreen( AlbumMenu( originalAlbum = Album(albumWithSongsLocal.album, albumWithSongsLocal.artists), navController = navController, - onDismiss = menuState::dismiss + onDismiss = menuState::dismiss, + selectAction = { selection = true } ) } } @@ -353,68 +364,125 @@ fun AlbumScreen( } } - itemsIndexed( - items = albumWithSongsLocal.songs, - key = { _, song -> song.id } - ) { index, song -> - SwipeToQueueBox( - item = song.toMediaItem(), - content = { - SongListItem( - song = song, - albumIndex = index + 1, - isActive = song.id == mediaMetadata?.id, - isPlaying = isPlaying, - showInLibraryIcon = true, - trailingContent = { - IconButton( - onClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - onDismiss = menuState::dismiss - ) - } - } - ) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = null + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp) + ) { + if (selection) { + val count = wrappedSongs?.count { it.isSelected } + Text(text = "$count elements selected", modifier = Modifier.weight(1f)) + IconButton( + onClick = { + if (count == wrappedSongs?.size) { + wrappedSongs?.forEach { it.isSelected = false } + }else { + wrappedSongs?.forEach { it.isSelected = true } + } + }, + ) { + Icon( + if (count == wrappedSongs?.size) Icons.Rounded.Deselect else Icons.Rounded.SelectAll, + contentDescription = null + ) + } + + IconButton( + onClick = { + wrappedSongs?.get(0)?.item?.toMediaItem() + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs?.filter { it.isSelected }!!.map { it.item }, + onDismiss = menuState::dismiss, + clearAction = {selection = false} ) } }, - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - if (song.id == mediaMetadata?.id) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.playQueue( - ListQueue( - title = albumWithSongsLocal.album.title, - items = albumWithSongsLocal.songs.map { it.toMediaItem() }, - startIndex = index, - playlistId = albumWithSongsLocal.album.playlistId + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null + ) + } + + IconButton( + onClick = { selection = false }, + ) { + Icon( + Icons.Rounded.Close, + contentDescription = null + ) + } + } + } + } + + + if (wrappedSongs != null) { + itemsIndexed( + items = wrappedSongs, + key = { _, song -> song.item.id } + ) { index, songWrapper -> + SwipeToQueueBox( + item = songWrapper.item.toMediaItem(), + content = { + SongListItem( + song = songWrapper.item, + albumIndex = index + 1, + isActive = songWrapper.item.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss ) - ) - } - }, - onLongClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - onDismiss = menuState::dismiss - ) + } } + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null + ) } - ) - ) - }, - snackbarHostState = snackbarHostState - ) + }, + isSelected = songWrapper.isSelected && selection, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (songWrapper.item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = albumWithSongsLocal.album.title, + items = albumWithSongsLocal.songs.map { it.toMediaItem() }, + startIndex = index, + playlistId = albumWithSongsLocal.album.playlistId + ) + ) + } + }, + onLongClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + }, + snackbarHostState = snackbarHostState + ) + } } } else { item { @@ -484,4 +552,4 @@ fun AlbumScreen( .align(Alignment.BottomCenter) ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/Screens.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/Screens.kt index 98eb0a8b1..81a322d5c 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/Screens.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/Screens.kt @@ -5,6 +5,7 @@ import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.Album +import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.LibraryMusic import androidx.compose.material.icons.rounded.MusicNote @@ -21,6 +22,7 @@ sealed class Screens( ) { data object Home : Screens(R.string.home, Icons.Rounded.Home, "home") data object Songs : Screens(R.string.songs, Icons.Rounded.MusicNote, "songs") + data object SongFolders : Screens(R.string.songs, Icons.Rounded.Folder, "songs_folders_screen") data object Artists : Screens(R.string.artists, Icons.Rounded.Person, "artists") data object Albums : Screens(R.string.albums, Icons.Rounded.Album, "albums") data object Playlists : Screens(R.string.playlists, Icons.AutoMirrored.Rounded.QueueMusic, "playlists") diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/artist/ArtistSongsScreen.kt index 9f12fea27..5acbb253d 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/artist/ArtistSongsScreen.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/artist/ArtistSongsScreen.kt @@ -61,6 +61,7 @@ import com.dd3boh.outertune.utils.rememberEnumPreference import com.dd3boh.outertune.utils.rememberPreference import com.dd3boh.outertune.viewmodels.ArtistSongsViewModel import com.zionhuang.innertube.YouTube +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -166,14 +167,17 @@ fun ArtistSongsScreen( val playlistId = YouTube.artist(artist?.id!!).getOrNull() ?.artist?.shuffleEndpoint?.playlistId - playerConnection.playQueue( - ListQueue( - title = context.getString(R.string.queue_all_songs), - items = songs.map { it.toMediaItem() }, - startIndex = index, - playlistId = playlistId + // for some reason this get called on the wrong thread and crashes, use main + CoroutineScope(Dispatchers.Main).launch { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index, + playlistId = playlistId + ) ) - ) + } } } }, diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/library/LibrarySongsFolderScreen.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/library/LibrarySongsFolderScreen.kt new file mode 100644 index 000000000..1d2b0f86d --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/library/LibrarySongsFolderScreen.kt @@ -0,0 +1,363 @@ +package com.dd3boh.outertune.ui.screens.library + +import android.annotation.SuppressLint +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.rounded.Close +import androidx.compose.material.icons.rounded.Deselect +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.SelectAll +import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.dd3boh.outertune.LocalPlayerAwareWindowInsets +import com.dd3boh.outertune.LocalPlayerConnection +import com.dd3boh.outertune.R +import com.dd3boh.outertune.constants.* +import com.dd3boh.outertune.extensions.toMediaItem +import com.dd3boh.outertune.extensions.togglePlayPause +import com.dd3boh.outertune.playback.queues.ListQueue +import com.dd3boh.outertune.ui.component.HideOnScrollFAB +import com.dd3boh.outertune.ui.component.LocalMenuState +import com.dd3boh.outertune.ui.component.SongFolderItem +import com.dd3boh.outertune.ui.component.SongListItem +import com.dd3boh.outertune.ui.component.SortHeader +import com.dd3boh.outertune.ui.component.SwipeToQueueBox +import com.dd3boh.outertune.ui.menu.SelectionSongMenu +import com.dd3boh.outertune.ui.menu.SongMenu +import com.dd3boh.outertune.ui.utils.getDirectoryTree +import com.dd3boh.outertune.utils.rememberEnumPreference +import com.dd3boh.outertune.utils.rememberPreference +import com.dd3boh.outertune.viewmodels.LibrarySongsViewModel +import com.zionhuang.music.ui.utils.ItemWrapper +import java.util.Stack + +@SuppressLint("StateFlowValueCalledInComposition") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun LibrarySongsFolderScreen( + navController: NavController, + viewModel: LibrarySongsViewModel = hiltViewModel(), + filterContent: @Composable() (() -> Unit)? = null +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + val folderStack = remember { viewModel.folderPositionStack } + val (flatSubfolders) = rememberPreference(FlatSubfoldersKey, defaultValue = true) + + val (sortType, onSortTypeChange) = rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) + + var inLocal by viewModel.inLocal + + val lazyListState = rememberLazyListState() + + // destroy old structure when pref changes + flatSubfolders.let { + viewModel.folderPositionStack = Stack() + } + + // initialize with first directory + if (folderStack.isEmpty()) { + val cachedTree = getDirectoryTree() + if (cachedTree == null) { + viewModel.getLocalSongs(context, viewModel.databaseLink) + } + + folderStack.push( + if (flatSubfolders) viewModel.localSongDirectoryTree.value.toFlattenedTree() + else viewModel.localSongDirectoryTree.value + ) + } + + // content to load for this page + var currDir by remember { + mutableStateOf(folderStack.peek()) + } + + val wrappedSongs = currDir.files.map { item -> ItemWrapper(item) }.toMutableList() + var selection by remember { + mutableStateOf(false) + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + filterContent?.let { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { + it() + } + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + if (selection) { + val count = wrappedSongs.count { it.isSelected } + Text(text = "$count elements selected", modifier = Modifier.weight(1f)) + IconButton( + onClick = { + if (count == wrappedSongs.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs.forEach { it.isSelected = true } + } + }, + ) { + Icon( + if (count == wrappedSongs.size) Icons.Rounded.Deselect else Icons.Rounded.SelectAll, + contentDescription = null + ) + } + + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs.filter { it.isSelected }.map { it.item }, + onDismiss = menuState::dismiss, + clearAction = { selection = false } + ) + } + }, + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null + ) + } + + IconButton( + onClick = { selection = false }, + ) { + Icon( + Icons.Rounded.Close, + contentDescription = null + ) + } + } else { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) + + Spacer(Modifier.weight(1f)) + + IconButton( + onClick = { selection = !selection }, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + if (selection) Icons.Rounded.Deselect else Icons.Rounded.SelectAll, + contentDescription = null + ) + } + + Text( + text = pluralStringResource( + R.plurals.n_song, currDir.toList().size, currDir.toList().size + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + + item( + key = "previous", + contentType = CONTENT_TYPE_FOLDER + ) { + SongFolderItem( + folderTitle = "..", + subtitle = "Previous folder", + modifier = Modifier + .clickable { + if (folderStack.size > 1) { + folderStack.pop() + currDir = folderStack.peek() + } else inLocal = false + } + ) + } + + // all subdirectories listed here + itemsIndexed( + items = currDir.subdirs, + key = { _, item -> item.uid }, + contentType = { _, _ -> CONTENT_TYPE_FOLDER } + ) { index, folder -> + SongFolderItem( + folder = folder, + subtitle = "${folder.toList().size} Song${if (folder.toList().size > 1) "" else "s"}", + modifier = Modifier + .combinedClickable { + // navigate to next page + currDir = folderStack.push(folder) + } + .animateItemPlacement(), + menuState = menuState, + navController = navController + ) + } + + // separator + if (currDir.subdirs.size > 0 && currDir.files.size > 0) { + item( + key = "folder_songs_divider", + ) { + HorizontalDivider( + thickness = DividerDefaults.Thickness, + modifier = Modifier.padding(20.dp) + ) + } + } + + // all songs get listed here + itemsIndexed( + items = wrappedSongs, + key = { _, item -> item.item.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, songWrapper -> + SwipeToQueueBox( + item = songWrapper.item.toMediaItem(), + content = { + SongListItem( + song = songWrapper.item, + isActive = songWrapper.item.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null + ) + } + }, + isSelected = songWrapper.isSelected && selection, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (songWrapper.item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + // I surely hope this applies to all in this folder... + items = currDir + .toList() + .map { it.toMediaItem() }, + startIndex = index + ) + ) + } + } else { + songWrapper.isSelected = !songWrapper.isSelected + } + }, + onLongClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) + }, + snackbarHostState = snackbarHostState + ) + } + } + + HideOnScrollFAB( + visible = currDir.toList().isNotEmpty(), + lazyListState = lazyListState, + icon = Icons.Rounded.Shuffle, + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = currDir.toList().shuffled().map { it.toMediaItem() } + ) + ) + } + ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } +} diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/library/LibrarySongsScreen.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/library/LibrarySongsScreen.kt index 9d484207f..bd91fcaa6 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/library/LibrarySongsScreen.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/library/LibrarySongsScreen.kt @@ -1,13 +1,17 @@ package com.dd3boh.outertune.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable 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.rounded.Close +import androidx.compose.material.icons.rounded.Deselect import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -19,6 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -39,13 +44,16 @@ import com.dd3boh.outertune.playback.queues.ListQueue import com.dd3boh.outertune.ui.component.ChipsRow import com.dd3boh.outertune.ui.component.HideOnScrollFAB import com.dd3boh.outertune.ui.component.LocalMenuState +import com.dd3boh.outertune.ui.component.SongFolderItem import com.dd3boh.outertune.ui.component.SongListItem import com.dd3boh.outertune.ui.component.SortHeader import com.dd3boh.outertune.ui.component.SwipeToQueueBox +import com.dd3boh.outertune.ui.menu.SelectionSongMenu import com.dd3boh.outertune.ui.menu.SongMenu import com.dd3boh.outertune.utils.rememberEnumPreference import com.dd3boh.outertune.utils.rememberPreference import com.dd3boh.outertune.viewmodels.LibrarySongsViewModel +import com.zionhuang.music.ui.utils.ItemWrapper @OptIn(ExperimentalFoundationApi::class) @Composable @@ -69,6 +77,13 @@ fun LibrarySongsScreen( val songs by viewModel.allSongs.collectAsState() + val wrappedSongs = songs.map { item -> ItemWrapper(item) }.toMutableList() + var selection by remember { + mutableStateOf(false) + } + + var inLocal by viewModel.inLocal + LaunchedEffect(Unit) { when (filter) { SongFilter.LIKED -> viewModel.syncLikedSongs() @@ -93,133 +108,217 @@ fun LibrarySongsScreen( ) } - val lazyListState = rememberLazyListState() - - Box( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) ) { - item( - key = "filter", - contentType = CONTENT_TYPE_HEADER - ) { - libraryFilterContent?.let { it() } ?: filterContent() - } - - item( - key = "header", - contentType = CONTENT_TYPE_HEADER - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 16.dp) + if (selection) { + val count = wrappedSongs.count { it.isSelected } + Text(text = "$count elements selected", modifier = Modifier.weight(1f)) + IconButton( + onClick = { + if (count == wrappedSongs.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs.forEach { it.isSelected = true } + } + }, ) { - SortHeader( - sortType = sortType, - sortDescending = sortDescending, - onSortTypeChange = onSortTypeChange, - onSortDescendingChange = onSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - SongSortType.CREATE_DATE -> R.string.sort_by_create_date - SongSortType.NAME -> R.string.sort_by_name - SongSortType.ARTIST -> R.string.sort_by_artist - SongSortType.PLAY_TIME -> R.string.sort_by_play_time - } + Icon( + if (count == wrappedSongs.size) Icons.Rounded.Deselect else Icons.Rounded.SelectAll, + contentDescription = null + ) + } + + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs.filter { it.isSelected }.map { it.item }, + onDismiss = menuState::dismiss, + clearAction = {selection = false} + ) } + }, + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null + ) + } + + IconButton( + onClick = { selection = false }, + ) { + Icon( + Icons.Rounded.Close, + contentDescription = null ) + } + } else { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) - Spacer(Modifier.weight(1f)) + Spacer(Modifier.weight(1f)) - Text( - text = pluralStringResource(R.plurals.n_song, songs.size, songs.size), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary + IconButton( + onClick = { selection = !selection }, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + if (selection) Icons.Rounded.Deselect else Icons.Rounded.SelectAll, + contentDescription = null ) } + + Text( + text = pluralStringResource(R.plurals.n_song, songs.size, songs.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) } + } + } - itemsIndexed( - items = songs, - key = { _, item -> item.id }, - contentType = { _, _ -> CONTENT_TYPE_SONG } - ) { index, song -> - - SwipeToQueueBox( - item = song.toMediaItem(), - content = { - SongListItem( - song = song, - isActive = song.id == mediaMetadata?.id, - isPlaying = isPlaying, - trailingContent = { - IconButton( - onClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - onDismiss = menuState::dismiss - ) - } - } - ) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = null - ) - } - }, + val lazyListState = rememberLazyListState() + + Box( + modifier = Modifier.fillMaxSize() + ) { + if (inLocal) { + LibrarySongsFolderScreen( + navController = navController, + filterContent = libraryFilterContent ?: filterContent + ) + } else { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { + libraryFilterContent?.let { it() } ?: filterContent() + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + if (!inLocal) headerContent() + } + + // Only show under library filter, subject to change + if (filter == SongFilter.LIBRARY) + item( + key = "song_folders" + ) { + // enter folders page + SongFolderItem( + folderTitle = "Internal Storage", modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - if (song.id == mediaMetadata?.id) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.playQueue( - ListQueue( - title = context.getString(R.string.queue_all_songs), - items = songs.map { it.toMediaItem() }, - startIndex = index + .clickable { inLocal = true } + .animateItemPlacement() + ) + } + + itemsIndexed( + items = wrappedSongs, + key = { _, item -> item.item.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, songWrapper -> + SwipeToQueueBox( + item = songWrapper.item.toMediaItem(), + content = { + SongListItem( + song = songWrapper.item, + isActive = songWrapper.item.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss ) - ) - } - }, - onLongClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - onDismiss = menuState::dismiss - ) + } } + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null + ) } - ) - .animateItemPlacement() - ) - }, - snackbarHostState = snackbarHostState - ) + }, + isSelected = songWrapper.isSelected && selection, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (songWrapper.item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index + ) + ) + } + } else { + songWrapper.isSelected = !songWrapper.isSelected + } + }, + onLongClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) + }, + snackbarHostState = snackbarHostState + ) + } } - } - HideOnScrollFAB( - visible = songs.isNotEmpty(), - lazyListState = lazyListState, - icon = Icons.Rounded.Shuffle, - onClick = { - playerConnection.playQueue( - ListQueue( - title = context.getString(R.string.queue_all_songs), - items = songs.shuffled().map { it.toMediaItem() } + HideOnScrollFAB( + visible = songs.isNotEmpty(), + lazyListState = lazyListState, + icon = Icons.Rounded.Shuffle, + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.shuffled().map { it.toMediaItem() } + ) ) - ) - } - ) + } + ) + } SnackbarHost( hostState = snackbarHostState, diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/playlist/AutoPlaylistScreen.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/playlist/AutoPlaylistScreen.kt index d45a6962c..e68516a4a 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/playlist/AutoPlaylistScreen.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/playlist/AutoPlaylistScreen.kt @@ -21,11 +21,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Deselect import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.OfflinePin import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -94,11 +97,13 @@ import com.dd3boh.outertune.ui.component.LocalMenuState import com.dd3boh.outertune.ui.component.SongListItem import com.dd3boh.outertune.ui.component.SortHeader import com.dd3boh.outertune.ui.component.SwipeToQueueBox +import com.dd3boh.outertune.ui.menu.SelectionSongMenu import com.dd3boh.outertune.ui.menu.SongMenu import com.dd3boh.outertune.utils.makeTimeString import com.dd3boh.outertune.utils.rememberEnumPreference import com.dd3boh.outertune.utils.rememberPreference import com.dd3boh.outertune.viewmodels.AutoPlaylistViewModel +import com.zionhuang.music.ui.utils.ItemWrapper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -119,6 +124,11 @@ fun AutoPlaylistScreen( val songs by viewModel.songs.collectAsState() + val wrappedSongs = songs.map { item -> ItemWrapper(item) }.toMutableList() + var selection by remember { + mutableStateOf(false) + } + val (sortType, onSortTypeChange) = rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) @@ -421,21 +431,75 @@ fun AutoPlaylistScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 16.dp) ) { - SortHeader( - sortType = sortType, - sortDescending = sortDescending, - onSortTypeChange = onSortTypeChange, - onSortDescendingChange = onSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - SongSortType.CREATE_DATE -> R.string.sort_by_create_date - SongSortType.NAME -> R.string.sort_by_name - SongSortType.ARTIST -> R.string.sort_by_artist - SongSortType.PLAY_TIME -> R.string.sort_by_play_time - } + if (selection) { + val count = wrappedSongs?.count { it.isSelected } + Text(text = "$count elements selected", modifier = Modifier.weight(1f)) + IconButton( + onClick = { + if (count == wrappedSongs?.size) { + wrappedSongs?.forEach { it.isSelected = false } + }else { + wrappedSongs?.forEach { it.isSelected = true } + } + }, + ) { + Icon( + if (count == wrappedSongs?.size) Icons.Rounded.Deselect else Icons.Rounded.SelectAll, + contentDescription = null + ) } - ) + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs?.filter { it.isSelected }!!.map { it.item }, + onDismiss = menuState::dismiss, + clearAction = {selection = false} + ) + } + }, + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null + ) + } + + IconButton( + onClick = { selection = false }, + ) { + Icon( + Icons.Rounded.Close, + contentDescription = null + ) + } + } else { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) + + IconButton( + onClick = { selection = !selection }, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + if (selection) Icons.Rounded.Deselect else Icons.Rounded.SelectAll, + contentDescription = null + ) + } + } Spacer(Modifier.weight(1f)) Text( @@ -457,15 +521,15 @@ fun AutoPlaylistScreen( itemsIndexed( - items = mutableSongs, - key = { _, song -> song.id } - ) { index, song -> + items = wrappedSongs, + key = { _, song -> song.item.id } + ) { index, songWrapper -> SwipeToQueueBox( - item = song.toMediaItem(), + item = songWrapper.item.toMediaItem(), content = { SongListItem( - song = song, - isActive = song.song.id == mediaMetadata?.id, + song = songWrapper.item, + isActive = songWrapper.item.song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, showLikedIcon = false, @@ -474,7 +538,7 @@ fun AutoPlaylistScreen( onClick = { menuState.show { SongMenu( - originalSong = song, + originalSong = songWrapper.item, playlistBrowseId = playlist.browseId, navController = navController, onDismiss = menuState::dismiss @@ -488,28 +552,33 @@ fun AutoPlaylistScreen( ) } }, + isSelected = songWrapper.isSelected && selection, modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.background) .combinedClickable( onClick = { - if (song.song.id == mediaMetadata?.id) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.playQueue( - ListQueue( - title = playlist.name, - items = songs.map { it.toMediaItem() }, - startIndex = index, - playlistId = playlist.browseId + if (!selection) { + if (songWrapper.item.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = playlist.name, + items = songs.map { it.toMediaItem() }, + startIndex = index, + playlistId = playlist.browseId + ) ) - ) + } + } else { + songWrapper.isSelected = !songWrapper.isSelected } }, onLongClick = { menuState.show { SongMenu( - originalSong = song, + originalSong = songWrapper.item, playlistBrowseId = playlist.browseId, navController = navController, onDismiss = menuState::dismiss diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/playlist/LocalPlaylistScreen.kt index 391a80098..7319a3d73 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/playlist/LocalPlaylistScreen.kt @@ -20,7 +20,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Deselect import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.Edit @@ -30,6 +32,7 @@ import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.OfflinePin import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -115,6 +118,8 @@ import com.dd3boh.outertune.utils.makeTimeString import com.dd3boh.outertune.utils.rememberEnumPreference import com.dd3boh.outertune.utils.rememberPreference import com.dd3boh.outertune.viewmodels.LocalPlaylistViewModel +import com.dd3boh.outertune.ui.menu.SelectionSongMenu +import com.zionhuang.music.ui.utils.ItemWrapper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -148,6 +153,10 @@ fun LocalPlaylistScreen( val (sortType, onSortTypeChange) = rememberEnumPreference(PlaylistSongSortTypeKey, PlaylistSongSortType.CUSTOM) val (sortDescending, onSortDescendingChange) = rememberPreference(PlaylistSongSortDescendingKey, true) var locked by rememberPreference(PlaylistEditLockKey, defaultValue = false) + val wrappedSongs = songs.map { item -> ItemWrapper(item) }.toMutableList() + var selection by remember { + mutableStateOf(false) + } val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -640,33 +649,98 @@ fun LocalPlaylistScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 16.dp) ) { - SortHeader( - sortType = sortType, - sortDescending = sortDescending, - onSortTypeChange = onSortTypeChange, - onSortDescendingChange = onSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - PlaylistSongSortType.CUSTOM -> R.string.sort_by_custom - PlaylistSongSortType.CREATE_DATE -> R.string.sort_by_create_date - PlaylistSongSortType.NAME -> R.string.sort_by_name - PlaylistSongSortType.ARTIST -> R.string.sort_by_artist - PlaylistSongSortType.PLAY_TIME -> R.string.sort_by_play_time - } - }, - modifier = Modifier.weight(1f) - ) - if (editable) { + + if (selection) { + val count = wrappedSongs.count { it.isSelected } + Text(text = "$count elements selected", modifier = Modifier.weight(1f)) + IconButton( + onClick = { + if (count == wrappedSongs.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs.forEach { it.isSelected = true } + } + }, + ) { + Icon( + if (selection) Icons.Rounded.Deselect else Icons.Rounded.SelectAll, + contentDescription = null + ) + } + + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs.filter { it.isSelected } + .map { it.item.song }, + songPosition = wrappedSongs.filter { it.isSelected }.map { it.item.map }, + onDismiss = menuState::dismiss, + clearAction = { + selection = false + wrappedSongs.clear() + } + ) + } + }, + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null + ) + } + IconButton( - onClick = { locked = !locked }, + onClick = { selection = false }, + ) { + Icon( + Icons.Rounded.Close, + contentDescription = null + ) + } + } else { + + + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + PlaylistSongSortType.CUSTOM -> R.string.sort_by_custom + PlaylistSongSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSongSortType.NAME -> R.string.sort_by_name + PlaylistSongSortType.ARTIST -> R.string.sort_by_artist + PlaylistSongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + }, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = { selection = !selection }, modifier = Modifier.padding(horizontal = 6.dp) ) { Icon( - imageVector = if (locked) Icons.Rounded.Lock else Icons.Rounded.LockOpen, + if (selection) Icons.Rounded.Deselect else Icons.Rounded.SelectAll, contentDescription = null ) } + + + if (editable) { + IconButton( + onClick = { locked = !locked }, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + imageVector = if (locked) Icons.Rounded.Lock else Icons.Rounded.LockOpen, + contentDescription = null + ) + } + } } } } @@ -683,19 +757,19 @@ fun LocalPlaylistScreen( } itemsIndexed( - items = mutableSongs, - key = { _, song -> song.map.id } - ) { index, song -> + items = wrappedSongs, + key = { _, song -> song.item.map.id } + ) { index, songWrapper -> ReorderableItem( reorderableState = reorderableState, - key = song.map.id + key = songWrapper.item.map.id ) { SwipeToQueueBox( - item = song.song.toMediaItem(), + item = songWrapper.item.song.toMediaItem(), content = { SongListItem( - song = song.song, - isActive = song.song.id == mediaMetadata?.id, + song = songWrapper.item.song, + isActive = songWrapper.item.song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, @@ -704,8 +778,8 @@ fun LocalPlaylistScreen( onClick = { menuState.show { SongMenu( - originalSong = song.song, - playlistSong = song, + originalSong = songWrapper.item.song, + playlistSong = songWrapper.item, playlistBrowseId = playlist?.playlist?.browseId, navController = navController, onDismiss = menuState::dismiss @@ -731,12 +805,14 @@ fun LocalPlaylistScreen( } } }, + isSelected = songWrapper.isSelected && selection, modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.background) .combinedClickable( onClick = { - if (song.song.id == mediaMetadata?.id) { + if (!selection) { + if (songWrapper.item.song.id == mediaMetadata?.id) { playerConnection.player.togglePlayPause() } else { playerConnection.playQueue( @@ -748,12 +824,15 @@ fun LocalPlaylistScreen( ) ) } + } else { + songWrapper.isSelected = !songWrapper.isSelected + } }, onLongClick = { menuState.show { SongMenu( - originalSong = song.song, - playlistSong = song, + originalSong = songWrapper.item.song, + playlistSong = songWrapper.item, playlistBrowseId = playlist?.playlist?.browseId, navController = navController, onDismiss = menuState::dismiss diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/AboutScreen.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/AboutScreen.kt index f4a8f12be..4499cee01 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/AboutScreen.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/AboutScreen.kt @@ -115,7 +115,7 @@ fun AboutScreen( Spacer(Modifier.height(4.dp)) Text( - text = "by Davide Garberi", + text = "By Davide Garberi & Michael Zh.", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary ) diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/AppearanceSettings.kt index f5a7b56c6..5b0e0130a 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/AppearanceSettings.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/AppearanceSettings.kt @@ -47,7 +47,6 @@ fun AppearanceSettings( val (pureBlack, onPureBlackChange) = rememberPreference(PureBlackKey, defaultValue = false) val (defaultOpenTab, onDefaultOpenTabChange) = rememberEnumPreference(DefaultOpenTabKey, defaultValue = NavigationTab.HOME) val (defaultOpenTabNew, onDefaultOpenTabNewChange) = rememberEnumPreference(DefaultOpenTabNewKey, defaultValue = NavigationTabNew.HOME) - val (lyricsPosition, onLyricsPositionChange) = rememberEnumPreference(LyricsTextPositionKey, defaultValue = LyricsPosition.CENTER) val (newInterfaceStyle, onNewInterfaceStyleChange) = rememberPreference(key = NewInterfaceKey, defaultValue = true) Column( @@ -117,19 +116,6 @@ fun AppearanceSettings( } ) } - EnumListPreference( - title = { Text(stringResource(R.string.lyrics_text_position)) }, - icon = { Icon(Icons.Rounded.Lyrics, null) }, - selectedValue = lyricsPosition, - onValueSelected = onLyricsPositionChange, - valueText = { - when (it) { - LyricsPosition.LEFT -> stringResource(R.string.left) - LyricsPosition.CENTER -> stringResource(R.string.center) - LyricsPosition.RIGHT -> stringResource(R.string.right) - } - } - ) } TopAppBar( diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/ExperimentalSettings.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/ExperimentalSettings.kt new file mode 100644 index 000000000..69d99f49d --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/ExperimentalSettings.kt @@ -0,0 +1,88 @@ +package com.dd3boh.outertune.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.DeveloperMode +import androidx.compose.material.icons.rounded.FolderCopy +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.dd3boh.outertune.LocalPlayerAwareWindowInsets +import com.dd3boh.outertune.R +import com.dd3boh.outertune.constants.DevSettingsKey +import com.dd3boh.outertune.constants.FlatSubfoldersKey +import com.dd3boh.outertune.ui.component.IconButton +import com.dd3boh.outertune.ui.component.PreferenceGroupTitle +import com.dd3boh.outertune.ui.component.SwitchPreference + +import com.dd3boh.outertune.ui.utils.backToMain +import com.dd3boh.outertune.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExperimentalSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + // state variables and such + val (flatSubfolders, onFlatSubfoldersChange) = rememberPreference(FlatSubfoldersKey, defaultValue = true) + val (devSettings, onDevSettingsChange) = rememberPreference(DevSettingsKey, defaultValue = false) + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + + PreferenceGroupTitle( + title = "We don't know where to put these yet" + ) + + // flatten subfolders + SwitchPreference( + title = { Text(stringResource(R.string.flat_subfolders_title)) }, + description = stringResource(R.string.flat_subfolders_description), + icon = { Icon(Icons.Rounded.FolderCopy, null) }, + checked = flatSubfolders, + onCheckedChange = onFlatSubfoldersChange + ) + + // dev settings + SwitchPreference( + title = { Text(stringResource(R.string.dev_settings_title)) }, + description = stringResource(R.string.dev_settings_description), + icon = { Icon(Icons.Rounded.DeveloperMode, null) }, + checked = devSettings, + onCheckedChange = onDevSettingsChange + ) + } + + + + + TopAppBar( + title = { Text(stringResource(R.string.experimental_settings_title)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LocalPlayerSettings.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LocalPlayerSettings.kt new file mode 100644 index 000000000..0405cb4ff --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LocalPlayerSettings.kt @@ -0,0 +1,401 @@ +package com.dd3boh.outertune.ui.screens.settings + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Autorenew +import androidx.compose.material.icons.rounded.Backup +import androidx.compose.material.icons.rounded.GraphicEq +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material.icons.rounded.TextFields +import androidx.compose.material.icons.rounded.WarningAmber +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment + +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.app.ActivityCompat.requestPermissions +import androidx.navigation.NavController +import com.dd3boh.outertune.LocalPlayerAwareWindowInsets +import com.dd3boh.outertune.R +import com.dd3boh.outertune.constants.AutomaticScannerKey +import com.dd3boh.outertune.constants.DevSettingsKey +import com.dd3boh.outertune.constants.LookupYtmArtistsKey +import com.dd3boh.outertune.constants.ScannerMatchCriteria +import com.dd3boh.outertune.constants.ScannerSensitivityKey +import com.dd3boh.outertune.constants.ScannerStrictExtKey +import com.dd3boh.outertune.constants.ScannerImpl +import com.dd3boh.outertune.constants.ScannerTypeKey +import com.dd3boh.outertune.db.MusicDatabase +import com.dd3boh.outertune.ui.component.EnumListPreference +import com.dd3boh.outertune.ui.component.IconButton +import com.dd3boh.outertune.ui.component.PreferenceEntry +import com.dd3boh.outertune.ui.component.PreferenceGroupTitle +import com.dd3boh.outertune.ui.component.SwitchPreference + +import com.dd3boh.outertune.ui.utils.backToMain +import com.dd3boh.outertune.ui.utils.localToRemoteArtist +import com.dd3boh.outertune.ui.utils.quickSync +import com.dd3boh.outertune.ui.utils.refreshLocal +import com.dd3boh.outertune.ui.utils.scanLocal +import com.dd3boh.outertune.ui.utils.syncDB +import com.dd3boh.outertune.ui.utils.unloadScanner +import com.dd3boh.outertune.utils.purgeCache +import com.dd3boh.outertune.utils.rememberEnumPreference +import com.dd3boh.outertune.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocalPlayerSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + context: Context, + database: MusicDatabase, +) { + val mediaPermissionLevel = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO + else Manifest.permission.READ_EXTERNAL_STORAGE + + val coroutineScope = rememberCoroutineScope() + var isScannerActive by remember { mutableStateOf(false) } + var isScanFinished by remember { mutableStateOf(false) } + var mediaPermission by remember { mutableStateOf(true) } + + val (scannerType, onScannerTypeChange) = rememberEnumPreference( + key = ScannerTypeKey, + defaultValue = ScannerImpl.MEDIASTORE_FFPROBE + ) + val (scannerSensitivity, onScannerSensitivityChange) = rememberEnumPreference( + key = ScannerSensitivityKey, + defaultValue = ScannerMatchCriteria.LEVEL_2 + ) + val (strictExtensions, onStrictExtensionsChange) = rememberPreference(ScannerStrictExtKey, defaultValue = false) + val (autoScan, onAutoScanChange) = rememberPreference(AutomaticScannerKey, defaultValue = true) + + var fullRescan by remember { mutableStateOf(false) } + val (lookupYtmArtists, onlookupYtmArtistsChange) = rememberPreference(LookupYtmArtistsKey, defaultValue = true) + + var (devSettings) = rememberPreference(DevSettingsKey, defaultValue = false) + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + // automatic scanner + SwitchPreference( + title = { Text(stringResource(R.string.auto_scanner_title)) }, + description = stringResource(R.string.auto_scanner_description), + icon = { Icon(Icons.Rounded.Autorenew, null) }, + checked = autoScan, + onCheckedChange = onAutoScanChange + ) + + + PreferenceGroupTitle( + title = stringResource(R.string.manual_scanner_title) + ) + + // scanner + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, // WHY WON'T YOU CENTER + + ) { + Button( + enabled = !isScannerActive, + onClick = { + if (isScannerActive) { + return@Button + } + + // check permission + if (context.checkSelfPermission(mediaPermissionLevel) + != PackageManager.PERMISSION_GRANTED + ) { + + Toast.makeText( + context, + "The scanner requires storage permissions", + Toast.LENGTH_SHORT + ).show() + + requestPermissions( + context as Activity, + arrayOf(mediaPermissionLevel), PackageManager.PERMISSION_GRANTED + ) + + mediaPermission = false + return@Button + } else if (context.checkSelfPermission(mediaPermissionLevel) + == PackageManager.PERMISSION_GRANTED + ) { + mediaPermission = true + } + + isScanFinished = false + isScannerActive = true + + Toast.makeText( + context, + "Starting full library scan this may take a while...", + Toast.LENGTH_SHORT + ).show() + coroutineScope.launch(Dispatchers.IO) { + // full rescan + if (fullRescan) { + val directoryStructure = scanLocal(context, database, scannerType).value + syncDB(database, directoryStructure.toList(), scannerSensitivity, strictExtensions, true) + unloadScanner() + + // start artist linking job + if (lookupYtmArtists) { + coroutineScope.launch(Dispatchers.IO) { + localToRemoteArtist(database) + } + } + } else { + // quick scan + val directoryStructure = scanLocal(context, database, ScannerImpl.MEDIASTORE).value + quickSync( + database, directoryStructure.toList(), scannerSensitivity, + strictExtensions, scannerType + ) + unloadScanner() + + // start artist linking job + if (lookupYtmArtists) { + coroutineScope.launch(Dispatchers.IO) { + localToRemoteArtist(database) + } + } + } + + purgeCache() + + isScannerActive = false + isScanFinished = true + } + } + ) { + Text( + text = if (isScannerActive) { + "Scanning..." + } else if (isScanFinished) { + "Scan complete" + } else if (!mediaPermission) { + "No Permission" + } else { + "Scan" + } + ) + } + + + // progress indicator + if (!isScannerActive) { + return@Row + } + + // padding hax + VerticalDivider( + modifier = Modifier.padding(5.dp) + ) + + CircularProgressIndicator( + modifier = Modifier + .size(32.dp), + color = MaterialTheme.colorScheme.secondary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + // scanner checkboxes + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = fullRescan, + onCheckedChange = { fullRescan = it } + ) + Text( + stringResource(R.string.scanner_variant_rescan), color = MaterialTheme.colorScheme.secondary, + fontSize = 14.sp + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = lookupYtmArtists, + onCheckedChange = onlookupYtmArtistsChange, + ) + Text( + stringResource(R.string.scanner_online_artist_linking), color = MaterialTheme.colorScheme.secondary, + fontSize = 14.sp + ) + } + } + + Row( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Rounded.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + + Text( + stringResource(R.string.scanner_warning), + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + + + PreferenceGroupTitle( + title = stringResource(R.string.scanner_settings_title) + ) + + // scanner type + EnumListPreference( + title = { Text(stringResource(R.string.scanner_type_title)) }, + icon = { Icon(Icons.Rounded.Speed, null) }, + selectedValue = scannerType, + onValueSelected = onScannerTypeChange, + valueText = { + when (it) { + ScannerImpl.MEDIASTORE -> stringResource(R.string.scanner_type_mediastore) + ScannerImpl.MEDIASTORE_FFPROBE -> stringResource(R.string.scanner_type_mediastore_ffprobe) + ScannerImpl.FFPROBE -> stringResource(R.string.scanner_type_ffprobe) + } + } + ) + + // scanner sensitivity + EnumListPreference( + title = { Text(stringResource(R.string.scanner_sensitivity_title)) }, + icon = { Icon(Icons.Rounded.GraphicEq, null) }, + selectedValue = scannerSensitivity, + onValueSelected = onScannerSensitivityChange, + valueText = { + when (it) { + ScannerMatchCriteria.LEVEL_1 -> stringResource(R.string.scanner_sensitivity_L1) + ScannerMatchCriteria.LEVEL_2 -> stringResource(R.string.scanner_sensitivity_L2) + ScannerMatchCriteria.LEVEL_3 -> stringResource(R.string.scanner_sensitivity_L3) + } + } + ) + + + // strict file ext + SwitchPreference( + title = { Text(stringResource(R.string.scanner_strict_file_name_title)) }, + description = stringResource(R.string.scanner_strict_file_name_description), + icon = { Icon(Icons.Rounded.TextFields, null) }, + checked = strictExtensions, + onCheckedChange = onStrictExtensionsChange + ) + + + if (devSettings) { + PreferenceGroupTitle( + title = stringResource(R.string.settings_debug) + ) + + PreferenceEntry( + title = { Text("DEBUG: Nuke local lib") }, + icon = { Icon(Icons.Rounded.Backup, null) }, + onClick = { + Toast.makeText( + context, + "Nuking local files from database...", + Toast.LENGTH_SHORT + ).show() + coroutineScope.launch(Dispatchers.IO) { + Timber.tag("Settings").d("Nuke database status: ${database.nukeLocalData()}") + } + } + ) + + PreferenceEntry( + title = { Text("DEBUG: Force local to remote artist migration NOW") }, + icon = { Icon(Icons.Rounded.Backup, null) }, + onClick = { + Toast.makeText( + context, + "Starting migration...", + Toast.LENGTH_SHORT + ).show() + coroutineScope.launch(Dispatchers.IO) { + Timber.tag("Settings").d("Nuke database (MANUAL TRIGGERED) status: ${database.nukeLocalData()}") + } + } + ) + } + + } + + + + + TopAppBar( + title = { Text(stringResource(R.string.local_player_settings_title)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LyricsSettings.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LyricsSettings.kt new file mode 100644 index 000000000..1d36dbae6 --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LyricsSettings.kt @@ -0,0 +1,120 @@ +package com.dd3boh.outertune.ui.screens.settings + + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material.icons.rounded.ContentCut +import androidx.compose.material.icons.rounded.DeveloperMode +import androidx.compose.material.icons.rounded.FolderCopy +import androidx.compose.material.icons.rounded.Lyrics +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable + +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.dd3boh.outertune.LocalPlayerAwareWindowInsets +import com.dd3boh.outertune.R +import com.dd3boh.outertune.constants.DevSettingsKey +import com.dd3boh.outertune.constants.EnableKugouKey +import com.dd3boh.outertune.constants.FlatSubfoldersKey +import com.dd3boh.outertune.constants.LyricTrimKey +import com.dd3boh.outertune.constants.LyricsTextPositionKey +import com.dd3boh.outertune.constants.MultilineLrcKey +import com.dd3boh.outertune.db.MusicDatabase +import com.dd3boh.outertune.ui.component.EnumListPreference +import com.dd3boh.outertune.ui.component.IconButton +import com.dd3boh.outertune.ui.component.PreferenceGroupTitle +import com.dd3boh.outertune.ui.component.SwitchPreference + +import com.dd3boh.outertune.ui.utils.backToMain +import com.dd3boh.outertune.utils.rememberEnumPreference +import com.dd3boh.outertune.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LyricsSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + + // state variables and such + val (enableKugou, onEnableKugouChange) = rememberPreference(key = EnableKugouKey, defaultValue = true) + val (lyricsPosition, onLyricsPositionChange) = rememberEnumPreference(LyricsTextPositionKey, defaultValue = LyricsPosition.CENTER) + val (multilineLrc, onMultilineLrcChange) = rememberPreference(MultilineLrcKey, defaultValue = true) + val (lyricTrim, onLyricTrimChange) = rememberPreference(LyricTrimKey, defaultValue = false) + + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + // KuGou + SwitchPreference( + title = { Text(stringResource(R.string.enable_kugou)) }, + icon = { Icon(Icons.Rounded.Lyrics, null) }, + checked = enableKugou, + onCheckedChange = onEnableKugouChange + ) + + // lyrics position + EnumListPreference( + title = { Text(stringResource(R.string.lyrics_text_position)) }, + icon = { Icon(Icons.Rounded.Lyrics, null) }, + selectedValue = lyricsPosition, + onValueSelected = onLyricsPositionChange, + valueText = { + when (it) { + LyricsPosition.LEFT -> stringResource(R.string.left) + LyricsPosition.CENTER -> stringResource(R.string.center) + LyricsPosition.RIGHT -> stringResource(R.string.right) + } + } + ) + + // multiline lyrics + SwitchPreference( + title = { Text(stringResource(R.string.lyrics_multiline_title)) }, + description = stringResource(R.string.lyrics_multiline_description), + icon = { Icon(Icons.AutoMirrored.Rounded.Sort, null) }, + checked = multilineLrc, + onCheckedChange = onMultilineLrcChange + ) + + // trim (remove spaces around) lyrics + SwitchPreference( + title = { Text(stringResource(R.string.lyrics_trim_title)) }, + icon = { Icon(Icons.Rounded.ContentCut, null) }, + checked = lyricTrim, + onCheckedChange = onLyricTrimChange + ) + + } + + + TopAppBar( + title = { Text(stringResource(R.string.lyrics_settings_title)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/PlayerSettings.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/PlayerSettings.kt index 218bdd6ca..f2cca8b16 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/PlayerSettings.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/PlayerSettings.kt @@ -10,7 +10,10 @@ import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.GraphicEq +import androidx.compose.material.icons.rounded.Lyrics +import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.QueueMusic +import androidx.compose.material.icons.rounded.SdCard import androidx.compose.material.icons.rounded.VolumeUp import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -31,6 +34,7 @@ import com.dd3boh.outertune.constants.PersistentQueueKey import com.dd3boh.outertune.constants.SkipSilenceKey import com.dd3boh.outertune.ui.component.EnumListPreference import com.dd3boh.outertune.ui.component.IconButton +import com.dd3boh.outertune.ui.component.PreferenceEntry import com.dd3boh.outertune.ui.component.SwitchPreference import com.dd3boh.outertune.ui.utils.backToMain import com.dd3boh.outertune.utils.rememberEnumPreference @@ -83,6 +87,13 @@ fun PlayerSettings( checked = audioNormalization, onCheckedChange = onAudioNormalizationChange ) + + // lyrics settings + PreferenceEntry( + title = { Text(stringResource(R.string.lyrics_settings_title)) }, + icon = { Icon(Icons.Rounded.Lyrics, null) }, + onClick = { navController.navigate("settings/player/lyrics") } + ) } TopAppBar( diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/PrivacySettings.kt index c41fb9def..968dd46fb 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/PrivacySettings.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/PrivacySettings.kt @@ -51,7 +51,6 @@ fun PrivacySettings( val database = LocalDatabase.current val (pauseListenHistory, onPauseListenHistoryChange) = rememberPreference(key = PauseListenHistoryKey, defaultValue = false) val (pauseSearchHistory, onPauseSearchHistoryChange) = rememberPreference(key = PauseSearchHistoryKey, defaultValue = false) - val (enableKugou, onEnableKugouChange) = rememberPreference(key = EnableKugouKey, defaultValue = true) var showClearListenHistoryDialog by remember { mutableStateOf(false) @@ -150,12 +149,6 @@ fun PrivacySettings( icon = { Icon(Icons.Rounded.ClearAll, null) }, onClick = { showClearSearchHistoryDialog = true } ) - SwitchPreference( - title = { Text(stringResource(R.string.enable_kugou)) }, - icon = { Icon(Icons.Rounded.Lyrics, null) }, - checked = enableKugou, - onCheckedChange = onEnableKugouChange - ) } TopAppBar( diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/SettingsScreen.kt index 7c019c3d8..536ea1faf 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/SettingsScreen.kt @@ -11,8 +11,10 @@ import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Restore +import androidx.compose.material.icons.rounded.SdCard import androidx.compose.material.icons.rounded.Security import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material.icons.rounded.WarningAmber import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -57,6 +59,11 @@ fun SettingsScreen( icon = { Icon(Icons.Rounded.PlayArrow, null) }, onClick = { navController.navigate("settings/player") } ) + PreferenceEntry( + title = { Text(stringResource(R.string.local_player_settings_title)) }, + icon = { Icon(Icons.Rounded.SdCard, null) }, + onClick = { navController.navigate("settings/local") } + ) PreferenceEntry( title = { Text(stringResource(R.string.storage)) }, icon = { Icon(Icons.Rounded.Storage, null) }, @@ -72,6 +79,11 @@ fun SettingsScreen( icon = { Icon(Icons.Rounded.Restore, null) }, onClick = { navController.navigate("settings/backup_restore") } ) + PreferenceEntry( + title = { Text(stringResource(R.string.experimental_settings_title)) }, + icon = { Icon(Icons.Rounded.WarningAmber, null) }, + onClick = { navController.navigate("settings/experimental") } + ) PreferenceEntry( title = { Text(stringResource(R.string.about)) }, icon = { Icon(Icons.Rounded.Info, null) }, diff --git a/app/src/main/java/com/dd3boh/outertune/ui/utils/ItemWrapper.kt b/app/src/main/java/com/dd3boh/outertune/ui/utils/ItemWrapper.kt new file mode 100644 index 000000000..7d28a680f --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/ui/utils/ItemWrapper.kt @@ -0,0 +1,13 @@ +package com.zionhuang.music.ui.utils + +import androidx.compose.runtime.mutableStateOf + +class ItemWrapper(val item: T) { + private val _isSelected = mutableStateOf(true) + + var isSelected: Boolean + get() = _isSelected.value + set(value) { + _isSelected.value = value + } +} diff --git a/app/src/main/java/com/dd3boh/outertune/ui/utils/LocalMediaUtils.kt b/app/src/main/java/com/dd3boh/outertune/ui/utils/LocalMediaUtils.kt new file mode 100644 index 000000000..aa499e4b1 --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/ui/utils/LocalMediaUtils.kt @@ -0,0 +1,1097 @@ +package com.dd3boh.outertune.ui.utils + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.os.Build +import android.provider.MediaStore +import com.dd3boh.outertune.constants.ScannerMatchCriteria +import com.dd3boh.outertune.constants.ScannerImpl +import com.dd3boh.outertune.db.MusicDatabase +import com.dd3boh.outertune.db.entities.AlbumEntity +import com.dd3boh.outertune.db.entities.ArtistEntity +import com.dd3boh.outertune.db.entities.FormatEntity +import com.dd3boh.outertune.db.entities.Song +import com.dd3boh.outertune.db.entities.SongArtistMap +import com.dd3boh.outertune.db.entities.SongEntity +import com.dd3boh.outertune.models.toMediaMetadata +import com.dd3boh.outertune.utils.cache +import com.dd3boh.outertune.utils.retrieveImage +import com.dd3boh.outertune.utils.scanners.ExtraMetadataWrapper +import com.dd3boh.outertune.utils.scanners.FFProbeScanner +import com.dd3boh.outertune.utils.scanners.MetadataScanner +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.YouTube.search +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import timber.log.Timber +import java.io.File +import java.lang.Integer.parseInt +import java.time.LocalDateTime +import java.util.Locale + +const val TAG = "LocalMediaUtils" + +/** + * TODO: Implement a proper, much faster scanner + * Currently ffmpeg-kit will fail if you hit it with too many calls too quickly. + * You can try more than 8 jobs, but good luck. + * For easier debugging, uncomment SCANNER_CRASH_AT_FIRST_ERROR to stop at first error + */ +const val SCANNER_CRASH_AT_FIRST_ERROR = false // crash at ffprobe errors only +const val SYNC_SCANNER = false // true will not use multithreading for scanner +const val MAX_CONCURRENT_JOBS = 16 +const val SCANNER_DEBUG = false + +@OptIn(ExperimentalCoroutinesApi::class) +val scannerSession = Dispatchers.IO.limitedParallelism(MAX_CONCURRENT_JOBS) + +// stuff to make this work +const val sdcardRoot = "/storage/emulated/0/" +val testScanPaths = arrayListOf("Music") +val ARTIST_SEPARATORS = Regex("\\s*;\\s*|\\s*ft\\.\\s*|\\s*feat\\.\\s*|\\s*&\\s*", RegexOption.IGNORE_CASE) +var directoryUID = 0 +var cachedDirectoryTree: DirectoryTree? = null + + +// useful metadata +val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ARTIST_ID, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.RELATIVE_PATH, + MediaStore.Audio.Media.MIME_TYPE, + MediaStore.Audio.Media.BITRATE, + MediaStore.Audio.Media.SIZE, +) + + +/** + * A tree representation of local audio files + * + * @param path root directory start + */ +class DirectoryTree(path: String) { + companion object { + const val TAG = "DirectoryTree" + } + + /** + * Directory name + */ + var currentDir = path // file name + + /** + * Full parent directory path + */ + var parent: String = "" + + // folder contents + var subdirs = ArrayList() + var files = ArrayList() + + val uid = directoryUID + + init { + // increment uid + directoryUID++ + } + + /** + * Instantiate a directory tree directly + */ + constructor(path: String, files: ArrayList) : this(path) { + this.files = files + } + + fun insert(path: String, song: Song) { +// println("curr path =" + path) + + // add a file + if (path.indexOf('/') == -1) { + files.add(song) + Timber.tag(TAG).d("Adding song with path: $path") + return + } + + // there is still subdirs to process + var tmpPath = path + if (path[path.length - 1] == '/') { + tmpPath = path.substring(0, path.length - 1) + } + + // the first directory before the . + val subdirPath = tmpPath.substringBefore('/') + + // create subdirs if they do not exist, then insert + var existingSubdir: DirectoryTree? = null + subdirs.forEach { subdir -> + if (subdir.currentDir == subdirPath) { + existingSubdir = subdir + return@forEach + } + } + + if (existingSubdir == null) { + val tree = DirectoryTree(subdirPath) + tree.parent = "$parent/$currentDir" + tree.insert(tmpPath.substringAfter('/'), song) + subdirs.add(tree) + + } else { + existingSubdir!!.insert(tmpPath.substringAfter('/'), song) + } + } + + + /** + * Get the name of the file from full path, without any extensions + */ + private fun getFileName(path: String?): String? { + if (path == null) { + return null + } + return path.substringAfterLast('/').substringBefore('.') + } + + /** + * Retrieves song object at path + * + * @return song at path, or null if it does not exist + */ + fun getSong(path: String): Song? { + Timber.tag(TAG).d("Searching for song, at path: $path") + + // search for song in current dir + if (path.indexOf('/') == -1) { + val foundSong: Song = files.first { getFileName(it.song.localPath) == getFileName(path) } + Timber.tag(TAG).d("Searching for song, found?: ${foundSong.id} Name: ${foundSong.song.title}") + return foundSong + } + + // there is still subdirs to process + var tmpPath = path + if (path[path.length - 1] == '/') { + tmpPath = path.substring(0, path.length - 1) + } + + // the first directory before the . + val subdirPath = tmpPath.substringBefore('/') + + // scan for matching subdirectory + var existingSubdir: DirectoryTree? = null + subdirs.forEach { subdir -> + if (subdir.currentDir == subdirPath) { + existingSubdir = subdir + return@forEach + } + } + + // explore the subdirectory if it exists in + if (existingSubdir == null) { + return null + } else { + return existingSubdir!!.getSong(tmpPath.substringAfter('/')) + } + } + + + /** + * Retrieve a list of all the songs + */ + fun toList(): List { + val songs = ArrayList() + + fun traverseTree(tree: DirectoryTree, result: ArrayList) { + result.addAll(tree.files) + tree.subdirs.forEach { traverseTree(it, result) } + } + + traverseTree(this, songs) + return songs + } + + /** + * Retrieves a modified version of this DirectoryTree. + * All folders are recognized to be top level folders + */ + fun toFlattenedTree(): DirectoryTree { + val result = DirectoryTree(sdcardRoot) + getSubdirsRecursive(this, result.subdirs) + return result + } + + /** + * Crawl the directory tree, add the subdirectories with songs to the list + * @param it + * @param result + */ + private fun getSubdirsRecursive(it: DirectoryTree, result: ArrayList) { + if (it.files.size > 0) { + result.add(DirectoryTree(it.currentDir, it.files)) + } + + if (it.subdirs.size > 0) { + it.subdirs.forEach { getSubdirsRecursive(it, result) } + } + } +} + + +/** + * ========================== + * Actual local scanner utils + * ========================== + */ + + +var advancedScannerImpl: MetadataScanner? = null + +/** + * TODO: Docs here + */ +fun getScanner(scannerImpl: ScannerImpl): MetadataScanner? { + // kotlin won't let me return MetadataScanner even if it cant possibly be null broooo + return when (scannerImpl) { + ScannerImpl.FFPROBE, ScannerImpl.MEDIASTORE_FFPROBE -> + if (advancedScannerImpl is FFProbeScanner) advancedScannerImpl else FFProbeScanner() + ScannerImpl.MEDIASTORE -> null + } +} + +fun unloadScanner() { + advancedScannerImpl = null +} + +/** + * Dev uses + */ +fun refreshLocal(context: Context, database: MusicDatabase) = + refreshLocal(context, database, testScanPaths) + + +/** + * Quickly rebuild a skeleton directory tree of local files based on the database + * + * Notes: + * If files move around, that's on you to re run the scanner. + * If the metadata changes, that's also on you to re run the scanner. + * + * @param context Context + * @param scanPaths List of whitelist paths to scan under. This assumes + * the current directory is /storage/emulated/0/ a.k.a, /sdcard. + * For example, to scan under Music and Documents/songs --> ("Music", Documents/songs) + */ +fun refreshLocal( + context: Context, + database: MusicDatabase, + scanPaths: ArrayList +): MutableStateFlow { + val newDirectoryStructure = DirectoryTree(sdcardRoot) + + // get songs from db + var existingSongs: List + runBlocking(Dispatchers.IO) { + existingSongs = database.allLocalSongs().first() + } + + // Query for audio files + val contentResolver: ContentResolver = context.contentResolver + val cursor = contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + "${MediaStore.Audio.Media.IS_MUSIC} != 0 AND ${MediaStore.Audio.Media.DATA} LIKE ?", + scanPaths.map { "$sdcardRoot$it%" }.toTypedArray(), // whitelist paths + null + ) + Timber.tag(TAG).d("------------ SCAN: Starting Quick Directory Rebuild ------------") + cursor?.use { localCursor -> + // Columns indices + val nameColumn = localCursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val pathColumn = localCursor.getColumnIndexOrThrow(MediaStore.Audio.Media.RELATIVE_PATH) + + while (localCursor.moveToNext()) { + val name = localCursor.getString(nameColumn) // file name + val path = localCursor.getString(pathColumn) + + if (SCANNER_DEBUG) + Timber.tag(TAG).d("Quick scanner: PATH: $path") + + // Build directory tree with existing files + val possibleMatch = existingSongs.firstOrNull() { it.song.localPath == "$sdcardRoot$path$name" } + + if (possibleMatch != null) { + newDirectoryStructure.insert("$path$name", possibleMatch) + } + + } + } + + cachedDirectoryTree = newDirectoryStructure + return MutableStateFlow(newDirectoryStructure) +} + +/** + * For passing along song metadata + */ +data class SongTempData( + val id: String, val path: String, val title: String, val duration: Int, val artist: String?, + val artistID: String?, val album: String?, val albumID: String?, val formatEntity: FormatEntity, +) + +/** + * Compiles a song with all it's necessary metadata. Unlike MediaStore, + * this also supports multiple artists, multiple genres (TBD), and a few extra details (TBD). + */ +fun advancedScan( + basicData: SongTempData, + database: MusicDatabase, + scannerImpl: ScannerImpl, +): Song { + val artists = ArrayList() +// var generes +// var year: String? = null + + // MediaStore mode + var rawArtists = basicData.artist + + try { + // decide which scanner to use + val scanner = getScanner(scannerImpl) + var ffmpegData: ExtraMetadataWrapper? = null + if (scannerImpl == ScannerImpl.MEDIASTORE_FFPROBE) { + ffmpegData = scanner?.getMediaStoreSupplement(basicData.path) + rawArtists = ffmpegData?.artists + } else if (scannerImpl == ScannerImpl.FFPROBE){ + ffmpegData = scanner?.getAllMetadata(basicData.path, basicData.formatEntity) + rawArtists = ffmpegData?.artists + } + + // parse data + rawArtists?.split(ARTIST_SEPARATORS)?.forEach { element -> + val artistVal = element.trim() + artists.add(ArtistEntity("LA${ArtistEntity.generateArtistId()}", artistVal, isLocal = true)) + } + + // file format info + if (scannerImpl == ScannerImpl.FFPROBE && ffmpegData?.format != null) { + database.query { + upsert( + ffmpegData.format!! + ) + } + } else { // MEDIASTORE_FFPROBE and MEDIASTORE + database.query { + upsert( + basicData.formatEntity + ) + } + } + } catch (e: Exception) { + if (SCANNER_CRASH_AT_FIRST_ERROR) { + throw Exception("HALTING AT FIRST SCANNER ERROR " + e.message) // debug + } + // fallback on media store + if (SCANNER_DEBUG) { + Timber.tag(TAG).d( + "ERROR READING ARTIST METADATA: ${e.message}" + + " Falling back on MediaStore for ${basicData.path}" + ) + e.printStackTrace() + } + + if (basicData.artist?.isNotBlank() == true) { + if (SCANNER_DEBUG) { + Timber.tag(TAG).d( + "Adding local artist with name: ${basicData.artist}" + ) + artists.add(ArtistEntity(ArtistEntity.generateArtistId(), basicData.artist, isLocal = true)) + } + } + } + + return Song( + SongEntity( + basicData.id, + basicData.title, + (basicData.duration / 1000), // we use seconds for duration + albumId = basicData.albumID, + albumName = basicData.album, + isLocal = true, + inLibrary = LocalDateTime.now(), + localPath = basicData.path + ), + artists, + // album not working + basicData.albumID?.let { + basicData.album?.let { it1 -> + AlbumEntity( + it, + title = it1, + duration = 0, + songCount = 1 + ) + } + } + ) +} + +/** + * Dev uses + */ +fun scanLocal(context: Context, database: MusicDatabase, scannerImpl: ScannerImpl) = + scanLocal(context, database, testScanPaths, scannerImpl) + +/** + * Scan MediaStore for songs given a list of paths to scan for. + * This will replace all data in the database for a given song. + * + * @param context Context + * @param scanPaths List of whitelist paths to scan under. This assumes + * the current directory is /storage/emulated/0/ a.k.a, /sdcard. + * For example, to scan under Music and Documents/songs --> ("Music", Documents/songs) + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun scanLocal( + context: Context, + database: MusicDatabase, + scanPaths: ArrayList, + scannerImpl: ScannerImpl, +): MutableStateFlow { + + val newDirectoryStructure = DirectoryTree(sdcardRoot) + val contentResolver: ContentResolver = context.contentResolver + + // Query for audio files + val cursor = contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + "${MediaStore.Audio.Media.IS_MUSIC} != 0 AND ${MediaStore.Audio.Media.DATA} LIKE ?", + scanPaths.map { "$sdcardRoot$it%" }.toTypedArray(), // whitelist paths + null + ) + Timber.tag(TAG).d("------------ SCAN: Starting Full Scanner ------------") + + val scannerJobs = ArrayList>() + runBlocking { + // MediaStore is our "basic" scanner & file discovery + cursor?.use { cursor -> + // Columns indices + val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val artistIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val albumIDColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) + val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.RELATIVE_PATH) + + val mimeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE) + val bitrateColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.BITRATE) + + while (cursor.moveToNext()) { + val id = SongEntity.generateSongId() + val name = cursor.getString(nameColumn) // file name + val title = cursor.getString(titleColumn) // song title + val duration = cursor.getInt(durationColumn) + val artist = cursor.getString(artistColumn) + val artistID = cursor.getString(artistIdColumn) + val albumID = cursor.getString(albumIDColumn) + val album = cursor.getString(albumColumn) + val path = cursor.getString(pathColumn) + + // extra stream info + val bitrate = cursor.getInt(bitrateColumn) + val mime = cursor.getString(mimeColumn) + + if (SCANNER_DEBUG) + Timber.tag(TAG).d("ID: $id, Name: $name, ARTIST: $artist, PATH: $path") + + // append song to list + // media store doesn't support multi artists... + // do not link album (and whatever song id) with youtube yet, figure that out later + + if (!SYNC_SCANNER) { + // use async scanner + scannerJobs.add( + async(scannerSession) { + advancedScan( + SongTempData( + id.toString(), "$sdcardRoot$path$name", title, duration, + artist, artistID, album, albumID, + FormatEntity( + id = id, + itag = -1, + mimeType = mime, + codecs = mime.substringAfter('/'), + bitrate = bitrate, + sampleRate = -1, + contentLength = duration.toLong(), + loudnessDb = null, + playbackUrl = null + ), + ), database, scannerImpl, + ) + } + ) + } else { + // force synchronous scanning of songs + val toInsert = advancedScan( + SongTempData( + id.toString(), "$sdcardRoot$path$name", title, duration, + artist, artistID, album, albumID, + FormatEntity( + id = id, + itag = -1, + mimeType = mime, + codecs = mime.substringAfter('/'), + bitrate = bitrate, + sampleRate = -1, + contentLength = duration.toLong(), + loudnessDb = null, + playbackUrl = null + ) + ), database, scannerImpl + ) + toInsert.song.localPath?.let { s -> + newDirectoryStructure.insert( + s.substringAfter(sdcardRoot), toInsert + ) + } + } + } + } + + if (!SYNC_SCANNER) { + // use async scanner + scannerJobs.awaitAll() + } + } + + // build the tree + scannerJobs.forEach { + val song = it.getCompleted() + + song.song.localPath?.let { s -> + newDirectoryStructure.insert( + s.substringAfter(sdcardRoot), song + ) + } + } + + cachedDirectoryTree = newDirectoryStructure + return MutableStateFlow(newDirectoryStructure) +} + +/** + * Search for an artist on YouTube Music. + * + * If no artist is found, create one locally + */ +fun youtubeArtistLookup(query: String): ArtistEntity? { + var ytmResult: ArtistEntity? = null + + // hit up YouTube for artist + runBlocking(Dispatchers.IO) { + search(query, YouTube.SearchFilter.FILTER_ARTIST).onSuccess { result -> + + val foundArtist = result.items.firstOrNull { + it.title.lowercase(Locale.getDefault()) == query.lowercase(Locale.getDefault()) + } ?: throw Exception("Failed to search: Artist not found on YouTube Music") + ytmResult = ArtistEntity( + foundArtist.id, + foundArtist.title, + foundArtist.thumbnail + ) + + if (SCANNER_DEBUG) + Timber.tag(TAG).d("Found remote artist: ${result.items.first().title}") + }.onFailure { + throw Exception("Failed to search on YouTube Music") + } + + } + + return ytmResult +} + +/** + * Update the Database with local files + * + * @param database + * @param newSongs + * @param matchStrength How lax should the scanner be + * @param strictFileNames Whether to consider file names + * @param refreshExisting Setting this this to true will updated existing songs + * with new information, else existing song's data will not be touched, regardless + * whether it was actually changed on disk + * + * Inserts a song if not found + * Updates a song information depending on if refreshExisting value + */ +fun syncDB( + database: MusicDatabase, + newSongs: List, + matchStrength: ScannerMatchCriteria, + strictFileNames: Boolean, + refreshExisting: Boolean? = false +) { + Timber.tag(TAG).d("------------ SYNC: Starting Local Library Sync ------------") + Timber.tag(TAG).d("Entries to process: ${newSongs.size}") + + newSongs.forEach { song -> + val querySong = database.searchSongsInclNotInLibrary(song.song.title) + + runBlocking(Dispatchers.IO) { + + // check if this song is known to the library + val songMatch = querySong.first().filter { + return@filter compareSong(it, song, matchStrength, strictFileNames) + } + + if (SCANNER_DEBUG) { + Timber.tag(TAG) + .d("Found songs that match: ${songMatch.size}, Total results from database: ${querySong.first().size}") + if (songMatch.isNotEmpty()) { + Timber.tag(TAG).d("FIRST Found songs ${songMatch.first().song.title}") + } + } + + + if (songMatch.isNotEmpty() && refreshExisting == true) { // known song, update the song info in the database + Timber.tag(TAG).d("Found in database, updating song: ${song.song.title}") + val songToUpdate = songMatch.first() + database.update(songToUpdate.song) + + // destroy existing artist links + database.unlinkSongArtists(songToUpdate.id) + + // update artists + var artistPos = 0 + song.artists.forEach { + val dbArtist = database.searchArtists(it.name).firstOrNull()?.firstOrNull() + + if (dbArtist == null) { + // artist does not exist in db, add it then link it + database.insert(it) + database.insert(SongArtistMap(songToUpdate.id, it.id, artistPos)) + } else { + // artist does exist in db, link to it + database.insert(SongArtistMap(songToUpdate.id, dbArtist.artist.id, artistPos)) + } + + artistPos++ + } + } else if (songMatch.isEmpty()) { // new song + Timber.tag(TAG).d("NOT found in database, adding song: ${song.song.title}") + database.insert(song.toMediaMetadata()) + } + // do not delete songs from database automatically, we just disable them + disableSongs(database) + } + } + +} + +/** + * A faster scanner implementation that adds new songs to the database, + * and does not touch older songs entires (apart from removing + * inacessable songs from libaray). + * + * No remote artist lookup is done + * + * WARNING: cachedDirectoryTree is not refreshed and may lead to inconsistencies. + * It is highly recommend to rebuild the tree after scanner operation + * + * @param newSongs List of songs. This is expecting a barebones DirectoryTree + * (only paths are necessary), thus you may use the output of refreshLocal().toList() + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun quickSync( + database: MusicDatabase, + newSongs: List, + matchCriteria: ScannerMatchCriteria, + strictFileNames: Boolean, + scannerImpl: ScannerImpl, +) { + Timber.tag(TAG).d("------------ SYNC: Starting Quick (additive delta) Library Sync ------------") + Timber.tag(TAG).d("Entries to process: ${newSongs.size}") + + val mData = MediaMetadataRetriever() + + runBlocking(Dispatchers.IO) { + // get list of all songs in db, then get songs unknown to the database + val allSongs = database.allLocalSongs().first() + val delta = newSongs.filterNot { + allSongs.any { dbSong -> compareSong(it, dbSong, matchCriteria, strictFileNames) } + } + + val artistsWithMetadata = ArrayList() + val scannerJobs = ArrayList>() + runBlocking { + // Get song basic metadata + delta.forEach { s -> + mData.setDataSource(s.song.localPath) + + val id = SongEntity.generateSongId() + val title = + mData.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE).let { it ?: "" } // song title + val duration = parseInt(mData.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!!) + val artist = mData.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) + val artistID = if (artist == null) ArtistEntity.generateArtistId() else null + val album = mData.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM) + val albumID = if (album == null) AlbumEntity.generateAlbumId() else null + // path should never be null since its coming from directory tree scanner + // but Kotlin is too dumb to care. Just ruthlessly suppress the error... + val path = "" + s.song.localPath + + // extra stream info + val bitrate = mData.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.let { parseInt(it) } + val mime = "" + mData.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) + var sampleRate = -1 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + sampleRate = mData.extractMetadata(MediaMetadataRetriever.METADATA_KEY_SAMPLERATE)?.let { parseInt(it) }!! + } + + + if (SCANNER_DEBUG) + Timber.tag(TAG).d("ID: $id, Title: $title, ARTIST: $artist, PATH: $path") + + // append song to list + // media store doesn't support multi artists... + // do not link album (and whatever song id) with youtube yet, figure that out later + + if (!SYNC_SCANNER) { + // use async scanner + scannerJobs.add( + async(scannerSession) { + advancedScan( + SongTempData( + id, path, title, duration, artist, artistID, album, albumID, + FormatEntity( + id = id, + itag = -1, + mimeType = mime, + codecs = mime.substringAfter('/'), + bitrate = bitrate?: -1, + sampleRate = sampleRate, + contentLength = duration.toLong(), + loudnessDb = null, + playbackUrl = null + ) + ), database, scannerImpl // no online artist lookup + ) + } + ) + } else { + // force synchronous scanning of songs + val toInsert = advancedScan( + SongTempData( + id, path, title, duration, artist, artistID, album, albumID, + FormatEntity( + id = id, + itag = -1, + mimeType = mime, + codecs = mime.substringAfter('/'), + bitrate = bitrate?: -1, + sampleRate = sampleRate, + contentLength = duration.toLong(), + loudnessDb = null, + playbackUrl = null + ) + ), database, scannerImpl + ) + artistsWithMetadata.add(toInsert) + } + } + } + + if (!SYNC_SCANNER) { + // use async scanner + scannerJobs.awaitAll() + } + + // add to finished list + scannerJobs.forEach { + artistsWithMetadata.add(it.getCompleted()) + } + + if (delta.isNotEmpty()) { + syncDB(database, artistsWithMetadata, matchCriteria, strictFileNames) + } + + disableSongs(database) + } +} + +/** + * Converts all local artists to remote artists if possible + */ +fun localToRemoteArtist(database: MusicDatabase) { + runBlocking(Dispatchers.IO) { + val allLocal = database.allLocalArtists().first() + val scannerJobs = ArrayList>() + + allLocal.forEach { element -> + val artistVal = element.name.trim() + + // check if this artist exists in DB already + val databaseArtistMatch = + runBlocking(Dispatchers.IO) { + database.searchArtists(artistVal).first().filter { artist -> + // only look for remote artists here + return@filter artist.artist.name == artistVal && !artist.artist.isLocalArtist + } + } + + if (SCANNER_DEBUG) + Timber.tag(TAG).d("ARTIST FOUND IN DB??? Results size: ${databaseArtistMatch.size}") + + scannerJobs.add( + async(scannerSession) { + // resolve artist from YTM if not found in DB + if (databaseArtistMatch.isEmpty()) { + try { + youtubeArtistLookup(artistVal)?.let { + // add new artist, switch all old references, then delete old one + database.insert(it) + swapArtists(element, it, database) + } + } catch (e: Exception) { + // don't touch anything if ytm fails --> keep old artist + } + } else { + // swap with database artist + swapArtists(element, databaseArtistMatch.first().artist, database) + } + } + ) + } + } +} + + +/** + * Swap all participation(s) with old artist to use new artist + * + * p.s. This is here instead of DatabaseDao because it won't compile there because + * "oooga boooga error in generated code" + */ +suspend fun swapArtists(old: ArtistEntity, new: ArtistEntity, database: MusicDatabase) { + if (database.artist(old.id).first() == null) { + throw Exception("Attempting to swap with non-existent old artist in database with id: ${old.id}") + } + if (database.artist(new.id).first() == null) { + throw Exception("Attempting to swap with non-existent new artist in database with id: ${new.id}") + } + + // update participation(s) + database.updateSongArtistMap(old.id, new.id) + database.updateAlbumArtistMap(old.id, new.id) + + // nuke old artist + database.delete(old) +} + +/** + * Remove inaccessible songs from the library + */ +private fun disableSongs(database: MusicDatabase) { + runBlocking(Dispatchers.IO) { + // get list of all songs in db + val allSongs = database.allLocalSongs().first() + + for (song in allSongs) { + if (song.song.localPath == null) { + database.inLibrary(song.id, null) + continue + } + + val f = File(song.song.localPath) + // we can't play non-existent file or if it becomes a directory + if (!f.exists() || f.isDirectory()) { + if (SCANNER_DEBUG) + Timber.tag(TAG).d("Disabling song ${song.song.localPath}") + database.inLibrary(song.song.id, null) + } + } + } +} + + +/** + * Destroys all local library data (local songs and artists, does not include YTM downloads) + * from the database + */ +fun nukeLocalDB(database: MusicDatabase) { + Timber.tag(TAG).w("NUKING LOCAL FILE LIBRARY FROM DATABASE! Nuke status: ${database.nukeLocalData()}") +} + +/** + * Destroys all local library data (local songs and artists, does not include YTM downloads) + * from the database, then rebuilds it. + * + * @param database + * @param newSongs + * @param matchCriteria How lax should the scanner be + * @param strictFileNames Whether to consider file names + */ +fun destructiveRescanDB( + database: MusicDatabase, + newSongs: List, + matchCriteria: ScannerMatchCriteria, + strictFileNames: Boolean +) { + nukeLocalDB(database) + syncDB(database, newSongs, matchCriteria, strictFileNames) +} + +/** + * Check if artists are the same + * + * Both null == same artists + * Either null == different artists + */ +fun compareArtist(a: List, b: List): Boolean { + if (a.isNotEmpty()) { + println(a.first().name) + } + if (b.isNotEmpty()) { + println(b.first().name) + } + + if (a.isEmpty() && b.isEmpty()) { + return true + } else if (a.isEmpty() || b.isEmpty()) { + return false + } + + // compare entries + if (a.size != b.size) { + return false + } + val matchingArtists = a.filter { artist -> + b.any { it.name.lowercase(Locale.getDefault()) == artist.name.lowercase(Locale.getDefault()) } + } + + return matchingArtists.size == a.size +} + +/** + * Check the similarity of a song + * + * @param a + * @param b + * @param matchStrength How lax should the scanner be + * @param strictFileNames Whether to consider file names + */ +fun compareSong(a: Song, b: Song, matchStrength: ScannerMatchCriteria, strictFileNames: Boolean): Boolean { + // if match file names + if (strictFileNames && + (a.song.localPath?.substringAfterLast('/') != + b.song.localPath?.substringAfterLast('/')) + ) { + return false + } + + /** + * Compare file paths + * + * I draw the "user error" line here + */ + fun closeEnough(): Boolean { + return a.song.localPath == b.song.localPath + } + + // compare songs based on scanner strength + return when (matchStrength) { + ScannerMatchCriteria.LEVEL_1 -> a.song.title == b.song.title + ScannerMatchCriteria.LEVEL_2 -> closeEnough() || (a.song.title == b.song.title && + compareArtist(a.artists, b.artists)) + + ScannerMatchCriteria.LEVEL_3 -> closeEnough() || (a.song.title == b.song.title && + compareArtist(a.artists, b.artists) /* && album compare goes here */ ) + } +} + +/** + * ========================== + * Various misc helpers + * ========================== + */ + + +/** + * Extract the album art from the audio file. The image is not resized + * (did you mean to use getLocalThumbnail(path: String?, resize: Boolean)?). + * + * @param path Full path of audio file + */ +fun getLocalThumbnail(path: String?): Bitmap? = getLocalThumbnail(path, false) + +/** + * Extract the album art from the audio file + * + * @param path Full path of audio file + * @param resize Whether to resize the Bitmap to a thumbnail size (300x300) + */ +fun getLocalThumbnail(path: String?, resize: Boolean): Bitmap? { + if (path == null) { + return null + } + // try cache lookup + val cachedImage = if (resize) { + retrieveImage(path)?.resizedImage + } else { + retrieveImage(path)?.image + } + + if (cachedImage == null) { + Timber.tag(TAG).d("Cache miss on $path") + } else { + return cachedImage + } + + val mData = MediaMetadataRetriever() + mData.setDataSource(path) + + var image: Bitmap = try { + val art = mData.embeddedPicture + BitmapFactory.decodeByteArray(art, 0, art!!.size) + } catch (e: Exception) { + cache(path, null, resize) + null + } ?: return null + + if (resize) { + image = Bitmap.createScaledBitmap(image, 300, 300, false) + } + + cache(path, image, resize) + return image +} + + +/** + * Get cached directory tree + */ +fun getDirectoryTree(): DirectoryTree? { + if (cachedDirectoryTree == null) { + return null + } + return cachedDirectoryTree +} \ No newline at end of file diff --git a/app/src/main/java/com/dd3boh/outertune/utils/ImageCacheManager.kt b/app/src/main/java/com/dd3boh/outertune/utils/ImageCacheManager.kt new file mode 100644 index 000000000..530d75007 --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/utils/ImageCacheManager.kt @@ -0,0 +1,59 @@ +package com.dd3boh.outertune.utils + +import android.graphics.Bitmap + +const val MAX_IMAGE_CACHE = 150 // max cached images to hold + +/** + * Cached image + */ +data class CachedBitmap(var path: String?, var image: Bitmap?, var resizedImage: Bitmap?) + +var bitmapCache = ArrayDeque() + +/** + * Retrieves an image from the cache + */ +fun retrieveImage(path: String): CachedBitmap? { + return bitmapCache.firstOrNull { + // don't listen to Kotlin, if you remove the null check, you break images. + it?.path == path + } +} + +/** + * Adds an image to the cache + */ +fun cache(path: String, image: Bitmap?, resize: Boolean) { + if (image == null) { + return + } + + // adhere to limit + if (bitmapCache.size >= MAX_IMAGE_CACHE) { + bitmapCache.removeFirst() + } + + val existingCached = retrieveImage(path) + if (existingCached == null) { + // add the image + if (resize) { + bitmapCache.addLast(CachedBitmap(path, null, image)) + } else { + bitmapCache.addLast(CachedBitmap(path, image, null)) + } + } else { + if (resize) { + existingCached.resizedImage = image + } else { + existingCached.image = image + } + } +} + +/** + * Removes all cached images + */ +fun purgeCache() { + bitmapCache = ArrayDeque() +} \ No newline at end of file diff --git a/app/src/main/java/com/dd3boh/outertune/utils/SyncUtils.kt b/app/src/main/java/com/dd3boh/outertune/utils/SyncUtils.kt index 24154245b..9e17bb44a 100644 --- a/app/src/main/java/com/dd3boh/outertune/utils/SyncUtils.kt +++ b/app/src/main/java/com/dd3boh/outertune/utils/SyncUtils.kt @@ -30,6 +30,7 @@ class SyncUtils @Inject constructor( database.likedSongsByNameAsc().first() .filterNot { it.id in songs.map(SongItem::id) } + .filterNot { it.song.isLocal } .forEach { database.update(it.song.localToggleLike()) } songs.forEach { song -> @@ -50,6 +51,7 @@ class SyncUtils @Inject constructor( database.songsByNameAsc().first() .filterNot { it.id in songs.map(SongItem::id) } + .filterNot { it.song.isLocal } .forEach { database.update(it.song.toggleLibrary()) } songs.forEach { song -> @@ -70,6 +72,7 @@ class SyncUtils @Inject constructor( database.albumsLikedByNameAsc().first() .filterNot { it.id in albums.map(AlbumItem::id) } + .filterNot { it.album.isLocal } .forEach { database.update(it.album.localToggleLike()) } albums.forEach { album -> @@ -93,6 +96,7 @@ class SyncUtils @Inject constructor( database.artistsBookmarkedByNameAsc().first() .filterNot { it.id in artists.map(ArtistItem::id) } + .filterNot { it.artist.isLocal } .forEach { database.update(it.artist.localToggleLike()) } artists.forEach { artist -> @@ -125,6 +129,8 @@ class SyncUtils @Inject constructor( val dbPlaylists = database.playlistsByNameAsc().first() dbPlaylists.filterNot { it.playlist.browseId in playlistList.map(PlaylistItem::id) } + .filterNot { it.playlist.browseId == null } + .filterNot { it.playlist.isLocal } .forEach { database.update(it.playlist.localToggleLike()) } playlistList.onEach { playlist -> diff --git a/app/src/main/java/com/dd3boh/outertune/utils/scanners/FFProbeScanner.kt b/app/src/main/java/com/dd3boh/outertune/utils/scanners/FFProbeScanner.kt new file mode 100644 index 000000000..060e71504 --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/utils/scanners/FFProbeScanner.kt @@ -0,0 +1,111 @@ +package com.dd3boh.outertune.utils.scanners + +import com.dd3boh.outertune.db.entities.FormatEntity +import timber.log.Timber +import wah.mikooomich.ffMetadataEx.FFprobeWrapper +import java.lang.Integer.parseInt +import java.lang.Long.parseLong + +const val DEBUG_SAVE_OUTPUT = false +const val EXTRACTOR_TAG = "FFProbeExtractor" + +class FFProbeScanner : MetadataScanner { + // load advanced scanner libs + init { + System.loadLibrary("avcodec") + System.loadLibrary("avdevice") + System.loadLibrary("ffprobejni") + System.loadLibrary("avfilter") + System.loadLibrary("avformat") + System.loadLibrary("avutil") + System.loadLibrary("swresample") + System.loadLibrary("swscale") + } + + /** + * Given a path to a file, extract necessary metadata + * + * @param path Full file path + */ + override fun getMediaStoreSupplement(path: String): ExtraMetadataWrapper { + Timber.tag(EXTRACTOR_TAG).d("Starting MediaStoreSupplement session on: $path") + val ffprobe = FFprobeWrapper() + val data = ffprobe.getAudioMetadata(path) + + if (DEBUG_SAVE_OUTPUT) { + Timber.tag(EXTRACTOR_TAG).d("Full output for: $path \n $data") + } + + var artists: String? = null + var genres: String? = null + var date: String? = null + + data.lines().forEach { + val tag = it.substringBefore(':') + when (tag) { + "ARTISTS" -> artists = it.substringAfter(':') + "ARTIST" -> artists = it.substringAfter(':') + "artist" -> artists = it.substringAfter(':') + "GENRE" -> genres = it.substringAfter(':') + "DATE" -> date = it.substringAfter(':') + else -> "" + } + } + + return ExtraMetadataWrapper(artists, genres, date, null) + } + + /** + * Given a path to a file, extract all necessary metadata + * + * @param path Full file path + */ + override fun getAllMetadata(path: String, og: FormatEntity): ExtraMetadataWrapper { + Timber.tag(EXTRACTOR_TAG).d("Starting Full Extractor session on: $path") + val ffprobe = FFprobeWrapper() + val data = ffprobe.getFullAudioMetadata(path) + + if (DEBUG_SAVE_OUTPUT) { + Timber.tag(EXTRACTOR_TAG).d("Full output for: $path \n $data") + } + + var artists: String? = null + var genres: String? = null + var date: String? = null + var codec: String? = null + var type: String? = null + var bitrate: String? = null + var sampleRate: String? = null + var channels: String? = null + var duration: String? = null + + data.lines().forEach { + val tag = it.substringBefore(':') + when (tag) { + // why the fsck does an error here get swallowed silently???? + "ARTISTS", "ARTIST", "artist" -> artists = it.substringAfter(':') + "GENRE" -> genres = it.substringAfter(':') + "DATE" -> date = it.substringAfter(':') + "codec" -> codec = it.substringAfter(':') + "type" -> type = it.substringAfter(':') + "bitrate" -> bitrate = it.substringAfter(':') + "sampleRate" -> sampleRate = it.substringAfter(':') + "channels" -> channels = it.substringAfter(':') + "duration" -> duration = it.substringAfter(':') + else -> "" + } + } + return ExtraMetadataWrapper(artists, genres, date, FormatEntity( + id = og.id, + itag = og.itag, + mimeType = og.mimeType, + codecs = codec?.trim() ?: og.codecs, + bitrate = bitrate?.let { parseInt(it.trim()) } ?: og.bitrate, + sampleRate = sampleRate?.let { parseInt(it.trim()) } ?: og.sampleRate, + contentLength = duration?.let { parseLong(it.trim()) } ?: og.contentLength, + loudnessDb = og.loudnessDb, + playbackUrl = og.playbackUrl + )) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/dd3boh/outertune/utils/scanners/MetadataScanner.kt b/app/src/main/java/com/dd3boh/outertune/utils/scanners/MetadataScanner.kt new file mode 100644 index 000000000..d5e8a142e --- /dev/null +++ b/app/src/main/java/com/dd3boh/outertune/utils/scanners/MetadataScanner.kt @@ -0,0 +1,31 @@ +package com.dd3boh.outertune.utils.scanners + +import com.dd3boh.outertune.db.entities.FormatEntity + + +/** + * Returns metadata information + */ +interface MetadataScanner { + /** + * Given a path to a file, extract necessary metadata MediaStore fails to + * deliver upon. Extracts artists, genres, and date + * + * @param path Full file path + */ + fun getMediaStoreSupplement(path: String): ExtraMetadataWrapper + + /** + * Given a path to a file, extract necessary metadata. For fields FFmpeg is + * unable to extract, use the provided FormatEntity data. + * + * @param path Full file path + * @param og Initial FormatEntity data to build upon + */ + fun getAllMetadata(path: String, og: FormatEntity): ExtraMetadataWrapper +} + +/** + * A wrapper containing extra raw metadata that MediaStore fails to read properly + */ +data class ExtraMetadataWrapper(val artists: String?, val genres: String?, val date: String?, var format: FormatEntity?) \ No newline at end of file diff --git a/app/src/main/java/com/dd3boh/outertune/viewmodels/LibraryViewModels.kt b/app/src/main/java/com/dd3boh/outertune/viewmodels/LibraryViewModels.kt index 3fd11513a..de0957e0c 100644 --- a/app/src/main/java/com/dd3boh/outertune/viewmodels/LibraryViewModels.kt +++ b/app/src/main/java/com/dd3boh/outertune/viewmodels/LibraryViewModels.kt @@ -3,6 +3,7 @@ package com.dd3boh.outertune.viewmodels import android.content.Context +import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -13,9 +14,12 @@ import com.dd3boh.outertune.db.MusicDatabase import com.dd3boh.outertune.db.entities.Album import com.dd3boh.outertune.db.entities.Artist import com.dd3boh.outertune.db.entities.Playlist +import com.dd3boh.outertune.db.entities.Song import com.dd3boh.outertune.extensions.reversed import com.dd3boh.outertune.extensions.toEnum import com.dd3boh.outertune.playback.DownloadUtil +import com.dd3boh.outertune.ui.utils.DirectoryTree +import com.dd3boh.outertune.ui.utils.refreshLocal import com.dd3boh.outertune.utils.SyncUtils import com.dd3boh.outertune.utils.dataStore import com.dd3boh.outertune.utils.reportException @@ -27,6 +31,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime +import java.util.Stack import javax.inject.Inject @HiltViewModel @@ -36,41 +41,19 @@ class LibrarySongsViewModel @Inject constructor( downloadUtil: DownloadUtil, private val syncUtils: SyncUtils, ) : ViewModel() { - val allSongs = context.dataStore.data - .map { - Triple( - it[SongFilterKey].toEnum(SongFilter.LIKED), - it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE), - (it[SongSortDescendingKey] ?: true) - ) - } - .distinctUntilChanged() - .flatMapLatest { (filter, sortType, descending) -> - when (filter) { - SongFilter.LIBRARY -> database.songs(sortType, descending) - SongFilter.LIKED -> database.likedSongs(sortType, descending) - SongFilter.DOWNLOADED -> downloadUtil.downloads.flatMapLatest { downloads -> - database.allSongs() - .flowOn(Dispatchers.IO) - .map { songs -> - songs.filter { - downloads[it.id]?.state == Download.STATE_COMPLETED - } - } - .map { songs -> - when (sortType) { - SongSortType.CREATE_DATE -> songs.sortedBy { downloads[it.id]?.updateTimeMs ?: 0L } - SongSortType.NAME -> songs.sortedBy { it.song.title } - SongSortType.ARTIST -> songs.sortedBy { song -> - song.artists.joinToString(separator = "") { it.name } - } - SongSortType.PLAY_TIME -> songs.sortedBy { it.song.totalPlayTime } - }.reversed(descending) - } - } - } - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + /** + * The top of the stack is the folder that the page will render. + * Clicking on a folder pushes, while the back button pops. + */ + var folderPositionStack = Stack() + val databaseLink = database + + val allSongs = syncAllSongs(context, database, downloadUtil) + + val localSongDirectoryTree = refreshLocal(context, database) + + val inLocal = mutableStateOf(false) fun syncLibrarySongs() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLibrarySongs() } @@ -79,6 +62,58 @@ class LibrarySongsViewModel @Inject constructor( fun syncLikedSongs() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedSongs() } } + + + /** + * Get local songs + * + * @return DirectoryTree + */ + fun getLocalSongs(context: Context, database: MusicDatabase): MutableStateFlow { + val directoryStructure = refreshLocal(context, database).value + return MutableStateFlow(directoryStructure) + } + + + fun syncAllSongs(context: Context, database: MusicDatabase, downloadUtil: DownloadUtil): StateFlow> { + + return context.dataStore.data + .map { + Triple( + it[SongFilterKey].toEnum(SongFilter.LIKED), + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE), + (it[SongSortDescendingKey] ?: true) + ) + } + .distinctUntilChanged() + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + SongFilter.LIBRARY -> database.songs(sortType, descending) + SongFilter.LIKED -> database.likedSongs(sortType, descending) + SongFilter.DOWNLOADED -> downloadUtil.downloads.flatMapLatest { downloads -> + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + // show local songs as under downloaded for now + downloads[it.id]?.state == Download.STATE_COMPLETED || it.song.isLocal + } + } + .map { songs -> + when (sortType) { + SongSortType.CREATE_DATE -> songs.sortedBy { downloads[it.id]?.updateTimeMs ?: 0L } + SongSortType.NAME -> songs.sortedBy { it.song.title } + SongSortType.ARTIST -> songs.sortedBy { song -> + song.artists.joinToString(separator = "") { it.name } + } + + SongSortType.PLAY_TIME -> songs.sortedBy { it.song.totalPlayTime } + }.reversed(descending) + } + } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } } @HiltViewModel diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1cf1afd2a..56f2cd65b 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,6 +90,8 @@ Search online Sync Advanced + Select all + Like all Date added @@ -201,6 +203,7 @@ Settings + Debug Appearance Enable dynamic theme Dark theme @@ -235,6 +238,7 @@ Skip silence Audio normalization Equalizer + Lyrics Storage Cache @@ -269,4 +273,45 @@ About App version + + Experimental + Enable developer settings + Reveals additional advanced settings intended for development use + + + Local Media + + Local scanner + + Scan automatically + Automatically scan for songs when opening the app. This does not reload metadata of existing songs. + + Manual scanner + By default, the scanner will NOT reload any metadata of existing songs unless you specify otherwise above. Depending on the size of your library, it make take a while. It is highly recommend to wait until the scanner to finish before proceeding. + + + Additional scanner settings + Strict file names + When enabled, file names will NOT be ignored. Ex. "Song.ogg" will be a different song from "Song.flac". Scanner sensitivity preference will still apply + Configure scanner sensitivity + Match title + Match title and artists + Match title, artists, albums + + Metadata extractor + MediaStore (Android\'s system scanner, possibly inaccurate) + Hybrid (MediaStore extractor, but FFProbe for artist extraction) + FFProbe (Prioritize FFmpeg extraction over MediaStore) + + Rescan the entire library and reload all songs\' metadata + Try to link local files\' artists with ones on YouTube Music + + + Flatten subfolders + Disable to preserve the file structure as on disk + + + Read multiline lyrics + Treat all the lines between sync points as one lyric text + Remove spaces around lyrics diff --git a/ffMetadataEx/.gitignore b/ffMetadataEx/.gitignore new file mode 100644 index 000000000..9e3ffeef5 --- /dev/null +++ b/ffMetadataEx/.gitignore @@ -0,0 +1,3 @@ +/build +/src/main/cpp/ffmpeg-android-maker +/src/main/cpp/src/ \ No newline at end of file diff --git a/ffMetadataEx/build.gradle.kts b/ffMetadataEx/build.gradle.kts new file mode 100644 index 000000000..61b3b6916 --- /dev/null +++ b/ffMetadataEx/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id("com.android.library") + kotlin("android") +} + +android { + namespace = "wah.mikooomich.ffmpegex" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + + externalNativeBuild { + cmake { + cppFlags("") + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + sourceSets { + getByName("main") { + jniLibs.srcDirs("src/main/cpp/ffmpeg-android-maker/output/lib/") + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + ndkVersion = "27.0.11718014" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(libs.timber) +} \ No newline at end of file diff --git a/ffMetadataEx/src/main/cpp/CMakeLists.txt b/ffMetadataEx/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..ab614bf1d --- /dev/null +++ b/ffMetadataEx/src/main/cpp/CMakeLists.txt @@ -0,0 +1,51 @@ +# CMakeLists.txt +cmake_minimum_required(VERSION 3.10.2) +project(ffMetadataEx) + + +add_library(avformat SHARED IMPORTED) +set_target_properties( # Specifies the target library. + avformat + + # Specifies the parameter you want to define. + PROPERTIES IMPORTED_LOCATION + + # Provides the path to the library you want to import. + ${CMAKE_SOURCE_DIR}/ffmpeg-android-maker/output/lib/${ANDROID_ABI}/libavformat.so ) + + +add_library(avutil SHARED IMPORTED) +set_target_properties( # Specifies the target library. + avutil + + # Specifies the parameter you want to define. + PROPERTIES IMPORTED_LOCATION + + # Provides the path to the library you want to import. + ${CMAKE_SOURCE_DIR}/ffmpeg-android-maker/output/lib/${ANDROID_ABI}/libavutil.so ) + +add_library(avcodec SHARED IMPORTED) +set_target_properties( # Specifies the target library. + avcodec + + # Specifies the parameter you want to define. + PROPERTIES IMPORTED_LOCATION + + # Provides the path to the library you want to import. + ${CMAKE_SOURCE_DIR}/ffmpeg-android-maker/output/lib/${ANDROID_ABI}/libavcodec.so ) + + +# Include FFmpeg headers +include_directories(${CMAKE_SOURCE_DIR}/ffmpeg-android-maker/output/include/${ANDROID_ABI}) + +add_library(ffprobejni SHARED ffprobejni.c) + +# Link FFmpeg libraries +target_link_libraries(ffprobejni + avformat + avutil + avcodec +) + +# Set the output directory for the .so file +set_target_properties(ffprobejni PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI}) \ No newline at end of file diff --git a/ffMetadataEx/src/main/cpp/ffprobejni.c b/ffMetadataEx/src/main/cpp/ffprobejni.c new file mode 100644 index 000000000..b8f40c110 --- /dev/null +++ b/ffMetadataEx/src/main/cpp/ffprobejni.c @@ -0,0 +1,172 @@ +#include +#include +#include +#include + +JNIEXPORT jstring JNICALL +Java_wah_mikooomich_ffMetadataEx_FFprobeWrapper_getAudioMetadata(JNIEnv* env, jobject obj, jstring filePath) { + const char* file_path = (*env)->GetStringUTFChars(env, filePath, NULL); + if (!file_path) { + return (*env)->NewStringUTF(env, "Error getting file path"); + } + + AVFormatContext* format_context = NULL; + if (avformat_open_input(&format_context, file_path, NULL, NULL) != 0) { + (*env)->ReleaseStringUTFChars(env, filePath, file_path); + return (*env)->NewStringUTF(env, "Error opening file"); + } + + // Retrieve stream information + if (avformat_find_stream_info(format_context, NULL) < 0) { + avformat_close_input(&format_context); + (*env)->ReleaseStringUTFChars(env, filePath, file_path); + return (*env)->NewStringUTF(env, "Error finding stream information"); + } + + // get audio stream + int audio_stream_index = -1; + for (int i = 0; i < format_context->nb_streams; i++) { + if (format_context->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { + audio_stream_index = i; + break; + } + } + + char string[10000] = ""; + + // container tags (audio containers e.g. flac, mp3) + AVDictionaryEntry* tag = NULL; + while ((tag = av_dict_get(format_context->metadata, "", tag, AV_DICT_IGNORE_SUFFIX))) { + strcat(string, tag->key); + strcat(string, ": "); + strcat(string, tag->value); + strcat(string, "\n"); + } + + // audio stream tags (ex. ogg) + if (audio_stream_index >= 0) { + AVStream* audio_stream = format_context->streams[audio_stream_index]; + tag = NULL; + while ((tag = av_dict_get(audio_stream->metadata, "", tag, AV_DICT_IGNORE_SUFFIX))) { + strcat(string, tag->key); + strcat(string, ": "); + strcat(string, tag->value); + strcat(string, "\n"); + } + } + + avformat_close_input(&format_context); + (*env)->ReleaseStringUTFChars(env, filePath, file_path); + + return (*env)->NewStringUTF(env, string); +} + + +JNIEXPORT jstring JNICALL +Java_wah_mikooomich_ffMetadataEx_FFprobeWrapper_getFullAudioMetadata(JNIEnv* env, jobject obj, jstring filePath) { + const char* file_path = (*env)->GetStringUTFChars(env, filePath, NULL); + if (!file_path) { + return (*env)->NewStringUTF(env, "Error getting file path"); + } + + AVFormatContext* format_context = NULL; + if (avformat_open_input(&format_context, file_path, NULL, NULL) != 0) { + (*env)->ReleaseStringUTFChars(env, filePath, file_path); + return (*env)->NewStringUTF(env, "Error opening file"); + } + + // Retrieve stream information + if (avformat_find_stream_info(format_context, NULL) < 0) { + avformat_close_input(&format_context); + (*env)->ReleaseStringUTFChars(env, filePath, file_path); + return (*env)->NewStringUTF(env, "Error finding stream information"); + } + + // get audio stream + int audio_stream_index = -1; + for (int i = 0; i < format_context->nb_streams; i++) { + if (format_context->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { + audio_stream_index = i; + break; + } + } + + char string[10000] = ""; + + // container tags (audio containers e.g. flac, mp3) + AVDictionaryEntry* tag = NULL; + while ((tag = av_dict_get(format_context->metadata, "", tag, AV_DICT_IGNORE_SUFFIX))) { + strcat(string, tag->key); + strcat(string, ": "); + strcat(string, tag->value); + strcat(string, "\n"); + } + + // bitrate + strcat(string, "\nbitrate: "); + char bitrate[20]; + sprintf(bitrate, "%lld", format_context->bit_rate); + strcat(string, bitrate); + + // audio stream tags (mixed containers e.g. ogg) + if (audio_stream_index >= 0) { + AVStream* audio_stream = format_context->streams[audio_stream_index]; + AVCodecParameters* codecpar = audio_stream->codecpar; + + // Add codec information + const char* codec_type = av_get_media_type_string(codecpar->codec_type); + if (codec_type != NULL) { + strcat(string, "\ntype: "); + strcat(string, codec_type); + } + + const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id); + if (codec != NULL) { + strcat(string, "\ncodec: "); + strcat(string, codec->long_name); + } else { + strcat(string, "\ncodec: Unknown"); + } + + // other stream data + strcat(string, "\nduration: "); + char duration[20]; + sprintf(duration, "%lld", audio_stream->duration); + strcat(string, duration); + + strcat(string, "\nsampleRate: "); + char sample_rate[20]; + sprintf(sample_rate, "%d", codecpar->sample_rate); + strcat(string, sample_rate); + + // Add number of channels + strcat(string, "\nchannels: "); + char channels[10]; + sprintf(channels, "%d", codecpar->ch_layout.nb_channels); + strcat(string, channels); + + // these show up as 0 + /* + * codecpar->bits_per_raw_sample + * codecpar->bits_per_coded_sample + * codecpar->frame_size + * codecpar->bit_rate (use container bitrate instead + */ + + strcat(string, "\n"); + + // add audio stream tags (ID3 metadata) + tag = NULL; + while ((tag = av_dict_get(audio_stream->metadata, "", tag, AV_DICT_IGNORE_SUFFIX))) { + strcat(string, tag->key); + strcat(string, ": "); + strcat(string, tag->value); + strcat(string, "\n"); + } + } + + avformat_close_input(&format_context); + (*env)->ReleaseStringUTFChars(env, filePath, file_path); + + return (*env)->NewStringUTF(env, string); +} diff --git a/ffMetadataEx/src/main/java/wah/mikooomich/ffMetadataEx/FFprobeWrapper.kt b/ffMetadataEx/src/main/java/wah/mikooomich/ffMetadataEx/FFprobeWrapper.kt new file mode 100644 index 000000000..ca3a97da6 --- /dev/null +++ b/ffMetadataEx/src/main/java/wah/mikooomich/ffMetadataEx/FFprobeWrapper.kt @@ -0,0 +1,18 @@ +package wah.mikooomich.ffMetadataEx + +/** + * Pain and suffering. + */ +class FFprobeWrapper { + external fun getAudioMetadata(filePath: String): String + + external fun getFullAudioMetadata(filePath: String): String + + +// companion object { +// // Used to load the 'ffmpegex' library on application startup. +// init { +// System.loadLibrary("ffmpegex") +// } +// } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index ab9cd1a1a..de103fcc4 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,3 +15,6 @@ include(":app") include(":innertube") include(":kugou") include(":material-color-utilities") + +// you must enable self built in \app\build.gradle.kts should you choose to uncomment this +//include(":ffMetadataEx")