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")