diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 67f84832..fa541152 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -138,7 +138,7 @@ RoboTest: script: - curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash - gcloud auth activate-service-account --key-file .secure_files/firebase-key.json - - gcloud firebase test android run --project ultrasonic-61089 --type robo --app ultrasonic-release/${PACKAGE_APK} --robo-directives click:button1= --device model=Nexus6,version=21,locale=en,orientation=portrait --device model=Pixel3,version=28,locale=fr,orientation=landscape --device model=Pixel5,version=30,locale=zh,orientation=portrait + - gcloud firebase test android run --project ultrasonic-61089 --type robo --app ultrasonic-release/${PACKAGE_APK} --robo-directives click:button1= --device model=Nexus6,version=21,locale=en,orientation=portrait --device model=Pixel3,version=28,locale=fr,orientation=landscape rules: # Run when releasing a new tag - if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID diff --git a/build.gradle b/build.gradle index d9cf0212..1797e96b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { apply from: 'gradle/versions.gradle' @@ -10,6 +12,7 @@ buildscript { repositories { google() mavenCentral() + gradlePluginPortal() maven { url "https://plugins.gradle.org/m2/" } } dependencies { @@ -26,21 +29,29 @@ allprojects { buildscript { repositories { mavenCentral() + gradlePluginPortal() google() } } repositories { mavenCentral() + gradlePluginPortal() google() } // Set Kotlin JVM target to the same for all subprojects - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + tasks.withType(KotlinCompile).configureEach { kotlinOptions { jvmTarget = "17" } } + + tasks.withType(JavaCompile).tap { + configureEach { + options.compilerArgs.add("-Xlint:deprecation") + } + } } wrapper { diff --git a/core/domain/build.gradle b/core/domain/build.gradle index 75d3d4a5..069c9605 100644 --- a/core/domain/build.gradle +++ b/core/domain/build.gradle @@ -1,11 +1,14 @@ +plugins { + alias libs.plugins.ksp +} + apply from: bootstrap.androidModule -apply plugin: 'kotlin-kapt' dependencies { implementation libs.core implementation libs.roomRuntime implementation libs.roomKtx - kapt libs.room + ksp libs.room } android { diff --git a/core/subsonic-api/build.gradle b/core/subsonic-api/build.gradle index 9f044c21..034da1e2 100644 --- a/core/subsonic-api/build.gradle +++ b/core/subsonic-api/build.gradle @@ -1,3 +1,7 @@ +plugins { + alias libs.plugins.ksp +} + apply from: bootstrap.kotlinModule dependencies { diff --git a/fastlane/metadata/android/en-US/changelogs/130.txt b/fastlane/metadata/android/en-US/changelogs/130.txt new file mode 100644 index 00000000..3d2ccf81 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130.txt @@ -0,0 +1,9 @@ +### Features +- Added custom buttons for shuffling the current queue and setting repeat mode (Android Auto) +- Properly handling nested directory structures (Android Auto) +- Add a toast when adding tracks to the playlist +- Allow pinning when offline + +### Dependencies +- Update koin +- Update media3 to v1.1.0 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 816a968e..6137c2c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,13 +3,13 @@ gradle = "8.1.1" navigation = "2.6.0" -gradlePlugin = "8.0.2" +gradlePlugin = "8.1.0" androidxcore = "1.10.1" ktlint = "0.43.2" -ktlintGradle = "11.4.2" +ktlintGradle = "11.5.0" detekt = "1.23.0" -preferences = "1.2.0" -media3 = "1.0.2" +preferences = "1.2.1" +media3 = "1.1.0" androidSupport = "1.6.0" materialDesign = "1.9.0" @@ -17,7 +17,8 @@ constraintLayout = "2.1.4" multidex = "2.0.1" room = "2.5.2" kotlin = "1.8.22" -kotlinxCoroutines = "1.7.1" +ksp = "1.8.22-1.0.11" +kotlinxCoroutines = "1.7.3" viewModelKtx = "2.6.1" swipeRefresh = "1.1.0" @@ -25,11 +26,11 @@ retrofit = "2.9.0" ## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24 jackson = "2.13.5" okhttp = "4.11.0" -koin = "3.3.2" +koin = "3.4.3" picasso = "2.8" junit4 = "4.13.2" -junit5 = "5.9.3" +junit5 = "5.10.0" mockito = "5.4.0" mockitoKotlin = "5.0.0" kluent = "1.73" @@ -100,3 +101,6 @@ kluentAndroid = { module = "org.amshove.kluent:kluent-android", versio mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + +[plugins] +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index c1962a79..033e24c4 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8707e8b5..c747538f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradle_scripts/android-module-bootstrap.gradle b/gradle_scripts/android-module-bootstrap.gradle index f84d8fe5..bfa5dac2 100644 --- a/gradle_scripts/android-module-bootstrap.gradle +++ b/gradle_scripts/android-module-bootstrap.gradle @@ -4,7 +4,7 @@ apply plugin: 'com.android.library' apply plugin: 'org.jetbrains.kotlin.android' apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle" -apply plugin: 'org.jetbrains.kotlin.kapt' +apply plugin: 'com.google.devtools.ksp' android { compileSdkVersion versions.compileSdk diff --git a/gradle_scripts/kotlin-module-bootstrap.gradle b/gradle_scripts/kotlin-module-bootstrap.gradle index c71044e9..0a69a824 100644 --- a/gradle_scripts/kotlin-module-bootstrap.gradle +++ b/gradle_scripts/kotlin-module-bootstrap.gradle @@ -2,7 +2,7 @@ * This module provides a base for for pure kotlin modules */ apply plugin: 'kotlin' -apply plugin: 'org.jetbrains.kotlin.kapt' +apply plugin: 'com.google.devtools.ksp' apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle" sourceSets { @@ -12,7 +12,6 @@ sourceSets { test.resources.srcDirs += "${projectDir}/src/integrationTest/resources" } - dependencies { api libs.kotlinStdlib diff --git a/gradlew b/gradlew index aeb74cbb..fcb6fca1 100755 --- a/gradlew +++ b/gradlew @@ -130,10 +130,13 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 9787150a..cea9d70f 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -1,6 +1,9 @@ +plugins { + alias libs.plugins.ksp +} + apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' -apply plugin: 'org.jetbrains.kotlin.kapt' apply plugin: "androidx.navigation.safeargs.kotlin" apply from: "../gradle_scripts/code_quality.gradle" @@ -9,8 +12,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 126 - versionName "4.6.3" + versionCode 128 + versionName "4.7.0" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk @@ -64,20 +67,20 @@ android { targetCompatibility JavaVersion.VERSION_17 } - kapt { - arguments { - arg("room.schemaLocation", "$rootDir/ultrasonic/schemas".toString()) - } + ksp { + arg("room.schemaLocation", "$rootDir/ultrasonic/schemas") } lint { baseline = file("lint-baseline.xml") abortOnError true warningsAsErrors true - disable 'IconMissingDensityFolder', 'VectorPath' - ignore 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity' warning 'ImpliedQuantity' + disable 'IconMissingDensityFolder', 'VectorPath' + disable 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity' disable 'ObsoleteLintCustomCheck' + // We manage dependencies on Gitlab with RenovateBot + disable 'GradleDependency' textReport true checkDependencies true } @@ -85,7 +88,7 @@ android { } -tasks.withType(Test) { +tasks.withType(Test).configureEach { useJUnitPlatform() } @@ -129,7 +132,7 @@ dependencies { implementation libs.rxAndroid implementation libs.multiType - kapt libs.room + ksp libs.room testImplementation libs.kotlinReflect testImplementation libs.junit @@ -141,6 +144,5 @@ dependencies { testImplementation libs.robolectric implementation libs.timber - } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index 160677b1..4cadda0d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -488,8 +488,7 @@ class NavigationActivity : AppCompatActivity() { val downloadHandler: DownloadHandler by inject() downloadHandler.addTracksToMediaController( songs = musicDirectory.getTracks(), - append = false, - playNext = false, + insertionMode = MediaPlayerManager.InsertionMode.CLEAR, autoPlay = true, shuffle = false, fragment = currentFragment, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/HeaderViewBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/HeaderViewBinder.kt index c2d25b0c..48e89673 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/HeaderViewBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/HeaderViewBinder.kt @@ -98,7 +98,7 @@ class HeaderViewBinder( holder.yearView.text = year val songs = resources.getQuantityString( - R.plurals.select_album_n_songs, item.childCount, + R.plurals.n_songs, item.childCount, item.childCount ) holder.songCountView.text = songs diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt index 5f0ef215..b959742e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt @@ -56,7 +56,7 @@ class BookmarksFragment : TrackCollectionFragment() { super.setupButtons(view) playNowButton!!.setOnClickListener { - playNow(getSelectedSongs()) + playNow(getSelectedTracks()) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt index 48815367..075b3e31 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt @@ -20,6 +20,7 @@ import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.GenericEntry import org.moire.ultrasonic.domain.Identifiable +import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.subsonic.DownloadAction @@ -133,27 +134,24 @@ abstract class EntryListFragment : MultiListFragment() { downloadHandler.fetchTracksAndAddToController( fragment, item.id, - append = false, + insertionMode = MediaPlayerManager.InsertionMode.CLEAR, autoPlay = true, - playNext = false, isArtist = isArtist ) R.id.menu_play_next -> downloadHandler.fetchTracksAndAddToController( fragment, item.id, - append = false, + insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT, autoPlay = true, - playNext = true, isArtist = isArtist ) R.id.menu_play_last -> downloadHandler.fetchTracksAndAddToController( fragment, item.id, - append = true, + insertionMode = MediaPlayerManager.InsertionMode.APPEND, autoPlay = false, - playNext = false, isArtist = isArtist ) R.id.menu_pin -> diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt index 4d2ffe2f..40d71cf3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -253,7 +253,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { insertionMode = MediaPlayerManager.InsertionMode.APPEND ) mediaPlayerManager.play(mediaPlayerManager.mediaItemCount - 1) - toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1)) + toast(context, resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1)) } private fun onVideoSelected(track: Track) { @@ -307,8 +307,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { songs.add(item) downloadHandler.addTracksToMediaController( songs = songs, - append = false, - playNext = false, + insertionMode = MediaPlayerManager.InsertionMode.CLEAR, autoPlay = true, shuffle = false, fragment = this, @@ -319,8 +318,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { songs.add(item) downloadHandler.addTracksToMediaController( songs = songs, - append = true, - playNext = true, + insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT, autoPlay = false, shuffle = false, fragment = this, @@ -331,8 +329,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { songs.add(item) downloadHandler.addTracksToMediaController( songs = songs, - append = true, - playNext = false, + insertionMode = MediaPlayerManager.InsertionMode.APPEND, autoPlay = false, shuffle = false, fragment = this, @@ -344,7 +341,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { toast( context, resources.getQuantityString( - R.plurals.select_album_n_songs_pinned, + R.plurals.n_songs_pinned, songs.size, songs.size ) @@ -356,7 +353,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { toast( context, resources.getQuantityString( - R.plurals.select_album_n_songs_downloaded, + R.plurals.n_songs_to_be_downloaded, songs.size, songs.size ) @@ -368,7 +365,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { toast( context, resources.getQuantityString( - R.plurals.select_album_n_songs_unpinned, + R.plurals.n_songs_unpinned, songs.size, songs.size ) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index b16fe044..108016dc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -159,7 +159,7 @@ open class TrackCollectionFragment( // Change the buttons if the status of any selected track changes rxBusSubscription += RxBus.trackDownloadStateObservable.subscribe { if (it.progress != null) return@subscribe - val selectedSongs = getSelectedSongs() + val selectedSongs = getSelectedTracks() if (!selectedSongs.any { song -> song.id == it.id }) return@subscribe triggerButtonUpdate(selectedSongs) } @@ -211,23 +211,15 @@ open class TrackCollectionFragment( } playNowButton?.setOnClickListener { - playNow(false) + playNow(MediaPlayerManager.InsertionMode.CLEAR, toast = true) } playNextButton?.setOnClickListener { - downloadHandler.addTracksToMediaController( - songs = getSelectedSongs(), - append = true, - playNext = true, - autoPlay = false, - shuffle = false, - playlistName = navArgs.playlistName, - this@TrackCollectionFragment - ) + playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, toast = true) } playLastButton!!.setOnClickListener { - playNow(true) + playNow(MediaPlayerManager.InsertionMode.APPEND, toast = true) } pinButton?.setOnClickListener { @@ -291,7 +283,7 @@ open class TrackCollectionFragment( return true } else if (item.itemId == R.id.menu_item_share) { shareHandler.createShare( - this@TrackCollectionFragment, getSelectedSongs(), + this@TrackCollectionFragment, getSelectedTracks(), refreshListView, cancellationToken!!, navArgs.id ) @@ -308,20 +300,37 @@ open class TrackCollectionFragment( } private fun playNow( - append: Boolean, - selectedSongs: List = getSelectedSongs() + insertionMode: MediaPlayerManager.InsertionMode, + selectedTracks: List = getSelectedTracks(), + toast: Boolean = false ) { - if (selectedSongs.isNotEmpty()) { + if (selectedTracks.isNotEmpty()) { downloadHandler.addTracksToMediaController( - songs = selectedSongs, - append = append, - playNext = false, - autoPlay = !append, + songs = selectedTracks, + insertionMode = insertionMode, + autoPlay = (insertionMode == MediaPlayerManager.InsertionMode.CLEAR), playlistName = null, fragment = this ) } else { - playAll(false, append) + playAll(false, insertionMode) + } + + if (toast) { + val stringInt = when (insertionMode) { + MediaPlayerManager.InsertionMode.CLEAR -> + R.plurals.n_songs_added_play_now + MediaPlayerManager.InsertionMode.AFTER_CURRENT -> + R.plurals.n_songs_added_after_current + MediaPlayerManager.InsertionMode.APPEND -> + R.plurals.n_songs_added_to_end + } + val msg = resources.getQuantityString( + stringInt, + selectedTracks.size, + selectedTracks.size + ) + Util.toast(requireContext(), msg) } } @@ -338,7 +347,10 @@ open class TrackCollectionFragment( } } - private fun playAll(shuffle: Boolean = false, append: Boolean = false) { + private fun playAll( + shuffle: Boolean = false, + insertionMode: MediaPlayerManager.InsertionMode = MediaPlayerManager.InsertionMode.CLEAR + ) { var hasSubFolders = false for (item in viewAdapter.getCurrentList()) { @@ -355,18 +367,16 @@ open class TrackCollectionFragment( downloadHandler.fetchTracksAndAddToController( fragment = this, id = navArgs.id!!, - append = append, - autoPlay = !append, + insertionMode = insertionMode, + autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND), shuffle = shuffle, - playNext = false, isArtist = isArtist ) } else { downloadHandler.addTracksToMediaController( songs = getAllSongs(), - append = append, - playNext = false, - autoPlay = !append, + insertionMode = insertionMode, + autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND), shuffle = shuffle, playlistName = navArgs.playlistName, fragment = this @@ -397,7 +407,7 @@ open class TrackCollectionFragment( } @Synchronized - fun triggerButtonUpdate(selection: List = getSelectedSongs()) { + fun triggerButtonUpdate(selection: List = getSelectedTracks()) { listModel.calculateButtonState(selection, ::updateButtonState) } @@ -414,14 +424,14 @@ open class TrackCollectionFragment( playNowButton?.isVisible = show.all playNextButton?.isVisible = show.all && multipleSelection playLastButton?.isVisible = show.all && multipleSelection - pinButton?.isVisible = show.all && !isOffline() && show.pin + pinButton?.isVisible = show.all && show.pin unpinButton?.isVisible = show.all && show.unpin downloadButton?.isVisible = show.all && show.download && !isOffline() deleteButton?.isVisible = show.all && show.delete } } - private fun downloadBackground(save: Boolean, tracks: List = getSelectedSongs()) { + private fun downloadBackground(save: Boolean, tracks: List = getSelectedTracks()) { var songs = tracks if (songs.isEmpty()) { @@ -436,7 +446,7 @@ open class TrackCollectionFragment( ) } - internal fun delete(songs: List = getSelectedSongs()) { + internal fun delete(songs: List = getSelectedTracks()) { downloadHandler.justDownload( action = DownloadAction.DELETE, fragment = this, @@ -444,7 +454,7 @@ open class TrackCollectionFragment( ) } - internal fun unpin(songs: List = getSelectedSongs()) { + internal fun unpin(songs: List = getSelectedTracks()) { downloadHandler.justDownload( action = DownloadAction.UNPIN, fragment = this, @@ -502,10 +512,7 @@ open class TrackCollectionFragment( val playAll = navArgs.autoPlay if (playAll && songCount > 0) { - playAll( - navArgs.shuffle, - false - ) + playAll(navArgs.shuffle, MediaPlayerManager.InsertionMode.CLEAR) } listModel.currentListIsSortable = true @@ -513,7 +520,7 @@ open class TrackCollectionFragment( Timber.i("Processed list") } - internal fun getSelectedSongs(): List { + internal fun getSelectedTracks(): List { // Walk through selected set and get the Entries based on the saved ids. return viewAdapter.getCurrentList().mapNotNull { if (it is Track && viewAdapter.isSelected(it.longId)) @@ -608,20 +615,13 @@ open class TrackCollectionFragment( when (menuItem.itemId) { R.id.song_menu_play_now -> { - playNow(false, songs) + playNow(MediaPlayerManager.InsertionMode.CLEAR, songs, true) } R.id.song_menu_play_next -> { - downloadHandler.addTracksToMediaController( - songs = songs, - append = true, - playNext = true, - autoPlay = false, - playlistName = navArgs.playlistName, - fragment = this@TrackCollectionFragment - ) + playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, songs, true) } R.id.song_menu_play_last -> { - playNow(true, songs) + playNow(MediaPlayerManager.InsertionMode.APPEND, songs, true) } R.id.song_menu_pin -> { downloadBackground(true, songs) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt index 49c33749..31fa05bd 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt @@ -35,6 +35,7 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException import org.moire.ultrasonic.domain.Share import org.moire.ultrasonic.fragment.FragmentTitle +import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.OfflineException import org.moire.ultrasonic.subsonic.DownloadAction @@ -165,10 +166,9 @@ class SharesFragment : Fragment(), KoinComponent { this, share.id, share.name, - append = false, + insertionMode = MediaPlayerManager.InsertionMode.CLEAR, autoPlay = true, - shuffle = false, - playNext = false, + shuffle = false ) } R.id.share_menu_play_shuffled -> { @@ -176,10 +176,9 @@ class SharesFragment : Fragment(), KoinComponent { this, share.id, share.name, - append = false, + insertionMode = MediaPlayerManager.InsertionMode.CLEAR, autoPlay = true, shuffle = true, - playNext = false, ) } R.id.share_menu_delete -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ArtworkBitmapLoader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ArtworkBitmapLoader.kt index b7b6c15a..29bb2e16 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ArtworkBitmapLoader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ArtworkBitmapLoader.kt @@ -11,7 +11,7 @@ import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri -import androidx.media3.session.BitmapLoader +import androidx.media3.common.util.BitmapLoader import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListeningExecutorService import com.google.common.util.concurrent.MoreExecutors diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt index 8ca449af..0154a05a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt @@ -7,16 +7,16 @@ package org.moire.ultrasonic.playback -import android.annotation.SuppressLint import android.os.Bundle import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_MIXED +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MIXED +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_PLAYLIST +import androidx.media3.common.Player import androidx.media3.common.Rating import androidx.media3.common.StarRating import androidx.media3.session.CommandButton @@ -45,7 +45,6 @@ import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.SearchCriteria import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.RatingManager import org.moire.ultrasonic.util.Util @@ -96,11 +95,8 @@ const val PLAY_COMMAND = "play " * MediaBrowserService implementation for e.g. Android Auto */ @Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember") -@SuppressLint("UnsafeOptInUsageError") -class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : - MediaLibraryService.MediaLibrarySession.Callback, KoinComponent { +class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callback, KoinComponent { - private val mediaPlayerManager by inject() private val activeServerProvider: ActiveServerProvider by inject() private val serviceJob = SupervisorJob() @@ -116,22 +112,59 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : private val isOffline get() = ActiveServerProvider.isOffline() private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId - private var customCommands: List - internal var customLayout = ImmutableList.of() + private val placeholderButton = getPlaceholderButton() + + private var heartIsCurrentlyOn = false + + // This button is used for an unstarred track, and its action will star the track + private val heartButtonToggleOn = + getHeartCommandButton( + SessionCommand( + PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, + Bundle.EMPTY + ), + willHeart = true + ) + + // This button is used for an starred track, and its action will star the track + private val heartButtonToggleOff = + getHeartCommandButton( + SessionCommand( + PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, + Bundle.EMPTY + ), + willHeart = false + ) + + private val shuffleButton: CommandButton + + private val repeatOffButton: CommandButton + private val repeatOneButton: CommandButton + private val repeatAllButton: CommandButton + + private val allCustomCommands: List + + val defaultCustomCommands: List init { - customCommands = - listOf( - // This button is used for an unstarred track, and its action will star the track - getHeartCommandButton( - SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY) - ), - // This button is used for an starred track, and its action will unstar the track - getHeartCommandButton( - SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY) - ) - ) - customLayout = ImmutableList.of(customCommands[0]) + val shuffleCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_SHUFFLE, Bundle.EMPTY) + shuffleButton = getShuffleCommandButton(shuffleCommand) + + val repeatCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_REPEAT_MODE, Bundle.EMPTY) + repeatOffButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_OFF) + repeatOneButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ONE) + repeatAllButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ALL) + + allCustomCommands = listOf( + heartButtonToggleOn, + heartButtonToggleOff, + shuffleButton, + repeatOffButton, + repeatOneButton, + repeatAllButton + ) + + defaultCustomCommands = listOf(heartButtonToggleOn, shuffleButton, repeatOffButton) } /** @@ -175,8 +208,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : "Root Folder", MEDIA_ROOT_ID, isPlayable = false, - folderType = FOLDER_TYPE_MIXED, - mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED + isBrowsable = true, + mediaType = MEDIA_TYPE_FOLDER_MIXED ), params ) @@ -188,14 +221,17 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : controller: MediaSession.ControllerInfo ): MediaSession.ConnectionResult { Timber.i("onConnect") + val connectionResult = super.onConnect(session, controller) val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() - for (commandButton in customCommands) { + for (commandButton in allCustomCommands) { // Add custom command to available session commands. commandButton.sessionCommand?.let { availableSessionCommands.add(it) } } + session.player.repeatMode = Player.REPEAT_MODE_ALL + return MediaSession.ConnectionResult.accept( availableSessionCommands.build(), connectionResult.availablePlayerCommands @@ -203,26 +239,72 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : } override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { - if (!customLayout.isEmpty() && controller.controllerVersion != 0) { + if (controller.controllerVersion != 0) { // Let Media3 controller (for instance the MediaNotificationProvider) // know about the custom layout right after it connected. - session.setCustomLayout(customLayout) + with(session) { + setCustomLayout(session.buildCustomCommands(canShuffle = canShuffle())) + } } } - private fun getHeartCommandButton(sessionCommand: SessionCommand): CommandButton { - val willHeart = - (sessionCommand.customAction == PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON) - return CommandButton.Builder() - .setDisplayName("Love") + private fun getHeartCommandButton(sessionCommand: SessionCommand, willHeart: Boolean) = + CommandButton.Builder() + .setDisplayName( + if (willHeart) + "Love" + else + "Dislike" + ) .setIconResId( - if (willHeart) R.drawable.ic_star_hollow - else R.drawable.ic_star_full + if (willHeart) + R.drawable.ic_star_hollow + else + R.drawable.ic_star_full + ) + .setSessionCommand(sessionCommand) + .setEnabled(true) + .build() + + private fun getShuffleCommandButton(sessionCommand: SessionCommand) = + CommandButton.Builder() + .setDisplayName("Shuffle") + .setIconResId(R.drawable.media_shuffle) + .setSessionCommand(sessionCommand) + .setEnabled(true) + .build() + + private fun getPlaceholderButton() = CommandButton.Builder() + .setDisplayName("Placeholder") + .setIconResId(android.R.color.transparent) + .setSessionCommand( + SessionCommand( + PlaybackService.CUSTOM_COMMAND_PLACEHOLDER, + Bundle.EMPTY + ) + ) + .setEnabled(false) + .build() + + private fun getRepeatModeButton(sessionCommand: SessionCommand, repeatMode: Int) = + CommandButton.Builder() + .setDisplayName( + when (repeatMode) { + Player.REPEAT_MODE_ONE -> "Repeat One" + Player.REPEAT_MODE_ALL -> "Repeat All" + else -> "Repeat None" + } + ) + .setIconResId( + when (repeatMode) { + Player.REPEAT_MODE_ONE -> R.drawable.media_repeat_one + Player.REPEAT_MODE_ALL -> R.drawable.media_repeat_all + else -> R.drawable.media_repeat_off + } ) .setSessionCommand(sessionCommand) .setEnabled(true) .build() - } override fun onGetItem( session: MediaLibraryService.MediaLibrarySession, @@ -266,18 +348,32 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : customCommand: SessionCommand, args: Bundle ): ListenableFuture { - Timber.i("onCustomCommand") + Timber.i("onCustomCommand %s", customCommand.customAction) var customCommandFuture: ListenableFuture? = null when (customCommand.customAction) { PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> { customCommandFuture = onSetRating(session, controller, HeartRating(true)) - updateCustomHeartButton(session, true) + updateCustomHeartButton(session, isHeart = true) } + PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> { customCommandFuture = onSetRating(session, controller, HeartRating(false)) - updateCustomHeartButton(session, false) + updateCustomHeartButton(session, isHeart = false) } + + PlaybackService.CUSTOM_COMMAND_SHUFFLE -> { + customCommandFuture = Futures.immediateFuture(SessionResult(RESULT_SUCCESS)) + shuffleCurrentPlaylist(session.player) + } + + PlaybackService.CUSTOM_COMMAND_REPEAT_MODE -> { + customCommandFuture = Futures.immediateFuture(SessionResult(RESULT_SUCCESS)) + + session.player.setNextRepeatMode() + session.updateCustomCommands() + } + else -> { Timber.d( "CustomCommand not recognized %s with extra %s", @@ -286,16 +382,23 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : ) } } - if (customCommandFuture != null) - return customCommandFuture - return super.onCustomCommand(session, controller, customCommand, args) + + return customCommandFuture + ?: super.onCustomCommand( + session, + controller, + customCommand, + args + ) } + override fun onSetRating( session: MediaSession, controller: MediaSession.ControllerInfo, rating: Rating ): ListenableFuture { val mediaItem = session.player.currentMediaItem + if (mediaItem != null) { if (rating is HeartRating) { mediaItem.toTrack().starred = rating.isHeart @@ -309,6 +412,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : rating ) } + return super.onSetRating(session, controller, rating) } @@ -378,6 +482,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : private fun onAddLegacyAutoItems( mediaItems: MutableList ): ListenableFuture> { + Timber.i("onAddLegacyAutoItems %s", mediaItems.first().mediaId) + val mediaIdParts = mediaItems.first().mediaId.split('|') val tracks = when (mediaIdParts.first()) { @@ -385,10 +491,12 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong( mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] ) + MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2]) MEDIA_ALBUM_SONG_ITEM -> playAlbumSong( mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] ) + MEDIA_SONG_STARRED_ID -> playStarredSongs() MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1]) MEDIA_SONG_RANDOM_ID -> playRandomSongs() @@ -400,57 +508,59 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode( mediaIdParts[1], mediaIdParts[2] ) + MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) else -> null } - if (tracks != null) { - return Futures.immediateFuture( - tracks.map { track -> track.toMediaItem() } - .toMutableList() - ) - } - - // Fallback to the original list - return Futures.immediateFuture(mediaItems) + return tracks + ?.let { + Futures.immediateFuture( + it.map { track -> track.toMediaItem() } + .toMutableList() + ) + } + ?: Futures.immediateFuture(mediaItems) } @Suppress("ReturnCount", "ComplexMethod") - fun onLoadChildren( + private fun onLoadChildren( parentId: String, ): ListenableFuture>> { Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId) val parentIdParts = parentId.split('|') - when (parentIdParts.first()) { - MEDIA_ROOT_ID -> return getRootItems() - MEDIA_LIBRARY_ID -> return getLibrary() - MEDIA_ARTIST_ID -> return getArtists() - MEDIA_ARTIST_SECTION -> return getArtists(parentIdParts[1]) - MEDIA_ALBUM_ID -> return getAlbums(AlbumListType.SORTED_BY_NAME) - MEDIA_ALBUM_PAGE_ID -> return getAlbums( + return when (parentIdParts.first()) { + MEDIA_ROOT_ID -> getRootItems() + MEDIA_LIBRARY_ID -> getLibrary() + MEDIA_ARTIST_ID -> getArtists() + MEDIA_ARTIST_SECTION -> getArtists(parentIdParts[1]) + MEDIA_ALBUM_ID -> getAlbums(AlbumListType.SORTED_BY_NAME) + MEDIA_ALBUM_PAGE_ID -> getAlbums( AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt() ) - MEDIA_PLAYLIST_ID -> return getPlaylists() - MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(AlbumListType.FREQUENT) - MEDIA_ALBUM_NEWEST_ID -> return getAlbums(AlbumListType.NEWEST) - MEDIA_ALBUM_RECENT_ID -> return getAlbums(AlbumListType.RECENT) - MEDIA_ALBUM_RANDOM_ID -> return getAlbums(AlbumListType.RANDOM) - MEDIA_ALBUM_STARRED_ID -> return getAlbums(AlbumListType.STARRED) - MEDIA_SONG_RANDOM_ID -> return getRandomSongs() - MEDIA_SONG_STARRED_ID -> return getStarredSongs() - MEDIA_SHARE_ID -> return getShares() - MEDIA_BOOKMARK_ID -> return getBookmarks() - MEDIA_PODCAST_ID -> return getPodcasts() - MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2]) - MEDIA_ARTIST_ITEM -> return getAlbumsForArtist( + + MEDIA_PLAYLIST_ID -> getPlaylists() + MEDIA_ALBUM_FREQUENT_ID -> getAlbums(AlbumListType.FREQUENT) + MEDIA_ALBUM_NEWEST_ID -> getAlbums(AlbumListType.NEWEST) + MEDIA_ALBUM_RECENT_ID -> getAlbums(AlbumListType.RECENT) + MEDIA_ALBUM_RANDOM_ID -> getAlbums(AlbumListType.RANDOM) + MEDIA_ALBUM_STARRED_ID -> getAlbums(AlbumListType.STARRED) + MEDIA_SONG_RANDOM_ID -> getRandomSongs() + MEDIA_SONG_STARRED_ID -> getStarredSongs() + MEDIA_SHARE_ID -> getShares() + MEDIA_BOOKMARK_ID -> getBookmarks() + MEDIA_PODCAST_ID -> getPodcasts() + MEDIA_PLAYLIST_ITEM -> getPlaylist(parentIdParts[1], parentIdParts[2]) + MEDIA_ARTIST_ITEM -> getAlbumsForArtist( parentIdParts[1], parentIdParts[2] ) - MEDIA_ALBUM_ITEM -> return getSongsForAlbum(parentIdParts[1], parentIdParts[2]) - MEDIA_SHARE_ITEM -> return getSongsForShare(parentIdParts[1]) - MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(parentIdParts[1]) - else -> return Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null)) + + MEDIA_ALBUM_ITEM -> getSongsForAlbum(parentIdParts[1], parentIdParts[2]) + MEDIA_SHARE_ITEM -> getSongsForShare(parentIdParts[1]) + MEDIA_PODCAST_ITEM -> getPodcastEpisodes(parentIdParts[1]) + else -> Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null)) } } @@ -483,8 +593,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : mediaItems.add( album.title ?: "", listOf(MEDIA_ALBUM_ITEM, album.id, album.name) - .joinToString("|"), - FOLDER_TYPE_ALBUMS + .joinToString("|") ) } @@ -534,10 +643,12 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong( mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] ) + MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2]) MEDIA_ALBUM_SONG_ITEM -> playAlbumSong( mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] ) + MEDIA_SONG_STARRED_ID -> playStarredSongs() MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1]) MEDIA_SONG_RANDOM_ID -> playRandomSongs() @@ -549,6 +660,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode( mediaIdParts[1], mediaIdParts[2] ) + MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) else -> { listOf() @@ -573,7 +685,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : R.string.music_library_label, MEDIA_LIBRARY_ID, null, - folderType = FOLDER_TYPE_MIXED, + isBrowsable = true, + mediaType = MEDIA_TYPE_FOLDER_MIXED, icon = R.drawable.ic_library ) @@ -581,7 +694,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : R.string.main_artists_title, MEDIA_ARTIST_ID, null, - folderType = FOLDER_TYPE_ARTISTS, + isBrowsable = true, + mediaType = MEDIA_TYPE_FOLDER_ARTISTS, icon = R.drawable.ic_artist ) @@ -590,7 +704,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : R.string.main_albums_title, MEDIA_ALBUM_ID, null, - folderType = FOLDER_TYPE_ALBUMS, + isBrowsable = true, + mediaType = MEDIA_TYPE_FOLDER_ALBUMS, icon = R.drawable.ic_menu_browse ) @@ -598,7 +713,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : R.string.playlist_label, MEDIA_PLAYLIST_ID, null, - folderType = FOLDER_TYPE_PLAYLISTS, + isBrowsable = true, + mediaType = MEDIA_TYPE_FOLDER_PLAYLISTS, icon = R.drawable.ic_menu_playlists ) @@ -613,14 +729,16 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : R.string.main_songs_random, MEDIA_SONG_RANDOM_ID, R.string.main_songs_title, - folderType = FOLDER_TYPE_TITLES + isBrowsable = true, + mediaType = MEDIA_TYPE_PLAYLIST ) mediaItems.add( R.string.main_songs_starred, MEDIA_SONG_STARRED_ID, R.string.main_songs_title, - folderType = FOLDER_TYPE_TITLES + isBrowsable = true, + mediaType = MEDIA_TYPE_PLAYLIST ) // Albums @@ -634,28 +752,28 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : R.string.main_albums_recent, MEDIA_ALBUM_RECENT_ID, R.string.main_albums_title, - folderType = FOLDER_TYPE_ALBUMS + mediaType = MEDIA_TYPE_FOLDER_ALBUMS, ) mediaItems.add( R.string.main_albums_frequent, MEDIA_ALBUM_FREQUENT_ID, R.string.main_albums_title, - folderType = FOLDER_TYPE_ALBUMS + mediaType = MEDIA_TYPE_FOLDER_ALBUMS, ) mediaItems.add( R.string.main_albums_random, MEDIA_ALBUM_RANDOM_ID, R.string.main_albums_title, - folderType = FOLDER_TYPE_ALBUMS + mediaType = MEDIA_TYPE_FOLDER_ALBUMS, ) mediaItems.add( R.string.main_albums_starred, MEDIA_ALBUM_STARRED_ID, R.string.main_albums_title, - folderType = FOLDER_TYPE_ALBUMS + mediaType = MEDIA_TYPE_FOLDER_ALBUMS, ) // Other @@ -704,8 +822,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : index.add(currentSection) mediaItems.add( currentSection, - listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|"), - FOLDER_TYPE_ARTISTS + listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|") ) } } @@ -713,8 +830,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : artists.map { artist -> mediaItems.add( artist.name ?: "", - listOf(childMediaId, artist.id, artist.name).joinToString("|"), - FOLDER_TYPE_ARTISTS + listOf(childMediaId, artist.id, artist.name).joinToString("|") ) } } @@ -744,8 +860,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : mediaItems.add( album.title ?: "", listOf(MEDIA_ALBUM_ITEM, album.id, album.name) - .joinToString("|"), - FOLDER_TYPE_ALBUMS + .joinToString("|") ) } return@future LibraryResult.ofItemList(mediaItems, null) @@ -768,15 +883,24 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|")) // TODO: Paging is not implemented for songs, is it necessary at all? - val items = songs.getTracks().take(DISPLAY_LIMIT) + val items = songs.getChildren().take(DISPLAY_LIMIT).toMutableList() + + items.sortWith { o1, o2 -> + if (o1.isDirectory && o2.isDirectory) + (o1.title ?: "").compareTo(o2.title ?: "") + else if (o1.isDirectory) + -1 + else + 1 + } + items.map { item -> if (item.isDirectory) mediaItems.add( item.title ?: "", - listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|"), - FOLDER_TYPE_TITLES + listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|") ) - else + else if (item is Track) mediaItems.add( item.toMediaItem( listOf( @@ -789,6 +913,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : ) } } + return@future LibraryResult.ofItemList(mediaItems, null) } } @@ -822,8 +947,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : mediaItems.add( album.title ?: "", listOf(MEDIA_ALBUM_ITEM, album.id, album.name) - .joinToString("|"), - FOLDER_TYPE_ALBUMS + .joinToString("|") ) } @@ -851,7 +975,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : playlist.name, listOf(MEDIA_PLAYLIST_ITEM, playlist.id, playlist.name) .joinToString("|"), - FOLDER_TYPE_PLAYLISTS + mediaType = MEDIA_TYPE_PLAYLIST, ) } return@future LibraryResult.ofItemList(mediaItems, null) @@ -945,7 +1069,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : mediaItems.add( podcast.title ?: "", listOf(MEDIA_PODCAST_ITEM, podcast.id).joinToString("|"), - FOLDER_TYPE_MIXED + mediaType = MEDIA_TYPE_FOLDER_MIXED, ) } return@future LibraryResult.ofItemList(mediaItems, null) @@ -1048,7 +1172,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : share.name ?: "", listOf(MEDIA_SHARE_ITEM, share.id) .joinToString("|"), - FOLDER_TYPE_MIXED + mediaType = MEDIA_TYPE_FOLDER_MIXED, ) } return@future LibraryResult.ofItemList(mediaItems, null) @@ -1226,14 +1350,16 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : private fun MutableList.add( title: String, mediaId: String, - folderType: Int + mediaType: Int = MEDIA_TYPE_MIXED, + isBrowsable: Boolean = false ) { val mediaItem = buildMediaItem( title, mediaId, isPlayable = false, - folderType = folderType + isBrowsable = isBrowsable, + mediaType = mediaType ) this.add(mediaItem) @@ -1244,8 +1370,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : resId: Int, mediaId: String, groupNameId: Int?, - browsable: Boolean = true, - folderType: Int = FOLDER_TYPE_MIXED, + isBrowsable: Boolean = true, + mediaType: Int = MEDIA_TYPE_FOLDER_MIXED, icon: Int? = null ) { val applicationContext = UApp.applicationContext() @@ -1253,14 +1379,15 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : val mediaItem = buildMediaItem( applicationContext.getString(resId), mediaId, - isPlayable = !browsable, - folderType = folderType, + isPlayable = !isBrowsable, + isBrowsable = isBrowsable, + imageUri = if (icon != null) { + Util.getUriToDrawable(applicationContext, icon) + } else null, group = if (groupNameId != null) { applicationContext.getString(groupNameId) } else null, - imageUri = if (icon != null) { - Util.getUriToDrawable(applicationContext, icon) - } else null + mediaType = mediaType ) this.add(mediaItem) @@ -1294,14 +1421,102 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : } } - fun updateCustomHeartButton( - session: MediaSession, - isHeart: Boolean - ) { - val command = if (isHeart) customCommands[1] else customCommands[0] - // Change the custom layout to contain the right heart button - customLayout = ImmutableList.of(command) - // Send the updated custom layout to controllers. - session.setCustomLayout(customLayout) + private fun Player.setNextRepeatMode() { + repeatMode = + when (repeatMode) { + Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL + Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE + else -> Player.REPEAT_MODE_OFF + } + } + + private fun MediaSession.updateCustomCommands() { + setCustomLayout( + buildCustomCommands( + heartIsCurrentlyOn, + canShuffle() + ) + ) + } + + fun updateCustomHeartButton(session: MediaSession, isHeart: Boolean) { + with(session) { + setCustomLayout( + buildCustomCommands( + isHeart = isHeart, + canShuffle = canShuffle() + ) + ) + } + } + + private fun MediaSession.canShuffle() = + player.mediaItemCount > 2 + + private fun MediaSession.buildCustomCommands( + isHeart: Boolean = false, + canShuffle: Boolean = false + ): ImmutableList { + Timber.d("building custom commands (isHeart = %s, canShuffle = %s)", isHeart, canShuffle) + + heartIsCurrentlyOn = isHeart + + return ImmutableList.copyOf( + buildList { + // placeholder must come first here because if there is no next button the first + // custom command button is place right next to the play/pause button + if ( + player.repeatMode != Player.REPEAT_MODE_ALL && + player.currentMediaItemIndex == player.mediaItemCount - 1 + ) + add(placeholderButton) + + // due to the previous placeholder this heart button will always appear to the left + // of the default playback items + add( + if (isHeart) + heartButtonToggleOff + else + heartButtonToggleOn + ) + + // both the shuffle and the active repeat mode button will end up in the overflow + // menu if both are available at the same time + if (canShuffle) + add(shuffleButton) + + add( + when (player.repeatMode) { + Player.REPEAT_MODE_ONE -> repeatOneButton + Player.REPEAT_MODE_ALL -> repeatAllButton + else -> repeatOffButton + } + ) + }.asIterable() + ) + } + + private fun shuffleCurrentPlaylist(player: Player) { + Timber.d("shuffleCurrentPlaylist") + + // 3 was chosen because that leaves at least two other songs to be shuffled around + @Suppress("MagicNumber") + if (player.mediaItemCount < 3) + return + + val mediaItemsToShuffle = mutableListOf() + + for (i in 0 until player.currentMediaItemIndex) { + mediaItemsToShuffle += player.getMediaItemAt(i) + } + + for (i in player.currentMediaItemIndex + 1 until player.mediaItemCount) { + mediaItemsToShuffle += player.getMediaItemAt(i) + } + + player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount) + player.removeMediaItems(0, player.currentMediaItemIndex) + + player.addMediaItems(mediaItemsToShuffle.shuffled()) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt deleted file mode 100644 index 50fa4c4f..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * CustomNotificationProvider.kt - * Copyright (C) 2009-2022 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ -package org.moire.ultrasonic.playback - -import android.content.Context -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultMediaNotificationProvider -import androidx.media3.session.MediaSession -import com.google.common.collect.ImmutableList -import org.koin.core.component.KoinComponent - -@UnstableApi -class CustomNotificationProvider(ctx: Context) : - DefaultMediaNotificationProvider(ctx), - KoinComponent { - - // By default the skip buttons are not shown in compact view. - // We add the COMMAND_KEY_COMPACT_VIEW_INDEX to show them - // See also: https://github.com/androidx/media/issues/410 - override fun getMediaButtons( - session: MediaSession, - playerCommands: Player.Commands, - customLayout: ImmutableList, - playWhenReady: Boolean - ): ImmutableList { - val commands = super.getMediaButtons(session, playerCommands, customLayout, playWhenReady) - - commands.forEachIndexed { index, command -> - command.extras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, index) - } - - return commands - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt index 952b8212..9ec36400 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -130,26 +130,19 @@ class PlaybackService : private fun initializeSessionAndPlayer() { if (isStarted) return - setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext())) - - // TODO: Remove minor code duplication with updateBackend() val desiredBackend = if (activeServerProvider.getActiveServer().jukeboxByDefault) { + Timber.i("Jukebox enabled by default") MediaPlayerManager.PlayerBackend.JUKEBOX } else { MediaPlayerManager.PlayerBackend.LOCAL } - player = if (activeServerProvider.getActiveServer().jukeboxByDefault) { - Timber.i("Jukebox enabled by default") - getJukeboxPlayer() - } else { - getLocalPlayer() - } + player = createNewBackend(desiredBackend) actualBackend = desiredBackend // Create browser interface - librarySessionCallback = AutoMediaBrowserCallback(this) + librarySessionCallback = AutoMediaBrowserCallback() // This will need to use the AutoCalls mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) @@ -157,10 +150,8 @@ class PlaybackService : .setBitmapLoader(ArtworkBitmapLoader()) .build() - if (!librarySessionCallback.customLayout.isEmpty()) { - // Send custom layout to legacy session. - mediaLibrarySession.setCustomLayout(librarySessionCallback.customLayout) - } + // Send custom layout to legacy session. + mediaLibrarySession.setCustomLayout(librarySessionCallback.defaultCustomCommands) // Set a listener to update the API client when the active server has changed rxBusSubscription += RxBus.activeServerChangedObservable.subscribe { @@ -213,11 +204,7 @@ class PlaybackService : player.removeListener(listener) player.release() - player = if (newBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) { - getJukeboxPlayer() - } else { - getLocalPlayer() - } + player = createNewBackend(newBackend) // Add fresh listeners player.addListener(listener) @@ -227,6 +214,14 @@ class PlaybackService : actualBackend = newBackend } + private fun createNewBackend(newBackend: MediaPlayerManager.PlayerBackend): Player { + return if (newBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) { + getJukeboxPlayer() + } else { + getLocalPlayer() + } + } + private fun getJukeboxPlayer(): Player { return JukeboxMediaPlayer() } @@ -425,6 +420,12 @@ class PlaybackService : "org.moire.ultrasonic.HEART_ON" const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = "org.moire.ultrasonic.HEART_OFF" + const val CUSTOM_COMMAND_SHUFFLE = + "org.moire.ultrasonic.SHUFFLE" + const val CUSTOM_COMMAND_PLACEHOLDER = + "org.moire.ultrasonic.PLACEHOLDER" + const val CUSTOM_COMMAND_REPEAT_MODE = + "org.moire.ultrasonic.REPEAT_MODE" private const val NOTIFICATION_ID = 3009 } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt index dfc06cd5..fb7d3b71 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -82,7 +82,10 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { companion object { // This is quite important, by setting the DeviceInfo the player is recognized by // Android as being a remote playback surface - val DEVICE_INFO = DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 10) + val DEVICE_INFO = DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE) + .setMinVolume(0) + .setMaxVolume(10) + .build() val running = AtomicBoolean() const val MAX_GAIN = 10 } @@ -206,14 +209,14 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { Player.COMMAND_CHANGE_MEDIA_ITEMS, Player.COMMAND_GET_TIMELINE, Player.COMMAND_GET_DEVICE_VOLUME, - Player.COMMAND_ADJUST_DEVICE_VOLUME, - Player.COMMAND_SET_DEVICE_VOLUME + Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS, + Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS, ) if (isPlaying) commandsBuilder.add(Player.COMMAND_STOP) if (playlist.isNotEmpty()) { commandsBuilder.addAll( Player.COMMAND_GET_CURRENT_MEDIA_ITEM, - Player.COMMAND_GET_MEDIA_ITEMS_METADATA, + Player.COMMAND_GET_METADATA, Player.COMMAND_PLAY_PAUSE, Player.COMMAND_PREPARE, Player.COMMAND_SEEK_BACK, @@ -284,6 +287,10 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {} override fun setDeviceVolume(volume: Int) { + setDeviceVolume(volume, 0) + } + + override fun setDeviceVolume(volume: Int, flags: Int) { gain = volume tasks.remove(SetGain::class.java) tasks.add(SetGain(floatGain)) @@ -299,17 +306,32 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { } } + @Deprecated("Deprecated in Java") override fun increaseDeviceVolume() { + increaseDeviceVolume(C.VOLUME_FLAG_SHOW_UI) + } + + override fun increaseDeviceVolume(flags: Int) { gain = (gain + 1).coerceAtMost(MAX_GAIN) deviceVolume = gain } + @Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() { + decreaseDeviceVolume(C.VOLUME_FLAG_SHOW_UI) + } + + override fun decreaseDeviceVolume(flags: Int) { gain = (gain - 1).coerceAtLeast(0) deviceVolume = gain } + @Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) { + setDeviceMuted(muted, C.VOLUME_FLAG_SHOW_UI) + } + + override fun setDeviceMuted(muted: Boolean, flags: Int) { gain = 0 deviceVolume = gain } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt index a22111be..c92abd23 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt @@ -67,6 +67,18 @@ abstract class JukeboxUnimplementedFunctions : Player { TODO("Not yet implemented") } + override fun replaceMediaItem(index: Int, mediaItem: MediaItem) { + TODO("Not yet implemented") + } + + override fun replaceMediaItems( + fromIndex: Int, + toIndex: Int, + mediaItems: MutableList + ) { + TODO("Not yet implemented") + } + override fun setPlayWhenReady(playWhenReady: Boolean) { TODO("Not yet implemented") } @@ -134,11 +146,6 @@ abstract class JukeboxUnimplementedFunctions : Player { override fun setPlaybackSpeed(speed: Float) { TODO("Not yet implemented") } - - override fun stop(reset: Boolean) { - TODO("Not yet implemented") - } - override fun getCurrentTracks(): Tracks { // TODO Dummy information is returned for now, this seems to work return Tracks.EMPTY diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt index 93588750..d07b4c52 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt @@ -460,7 +460,7 @@ class MediaPlayerManager( // We can't just use play(0,0) then all random playlists will start with the first track. // Additionally the shuffle order becomes clear on after some time, so we need to wait for // the right event, and can start playback only then. - if (autoPlay) { + if (autoPlay && controller?.isPlaying != true) { if (isShufflePlayEnabled) { deferredPlay = { val start = controller?.currentTimeline diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt index 2bae06e8..a0b4c156 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt @@ -68,27 +68,27 @@ class DownloadHandler( } successString = when (action) { DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString( - R.plurals.select_album_n_songs_downloaded, + R.plurals.n_songs_to_be_downloaded, tracksToDownload.size, tracksToDownload.size ) DownloadAction.UNPIN -> { fragment.resources.getQuantityString( - R.plurals.select_album_n_songs_unpinned, + R.plurals.n_songs_unpinned, tracksToDownload.size, tracksToDownload.size ) } DownloadAction.PIN -> { fragment.resources.getQuantityString( - R.plurals.select_album_n_songs_pinned, + R.plurals.n_songs_pinned, tracksToDownload.size, tracksToDownload.size ) } DownloadAction.DELETE -> { fragment.resources.getQuantityString( - R.plurals.select_album_n_songs_deleted, + R.plurals.n_songs_deleted, tracksToDownload.size, tracksToDownload.size ) @@ -104,10 +104,9 @@ class DownloadHandler( name: String? = "", isShare: Boolean = false, isDirectory: Boolean = true, - append: Boolean, + insertionMode: MediaPlayerManager.InsertionMode, autoPlay: Boolean, shuffle: Boolean = false, - playNext: Boolean, isArtist: Boolean = false ) { var successString: String? = null @@ -119,26 +118,28 @@ class DownloadHandler( withContext(Dispatchers.Main) { addTracksToMediaController( songs = songs, - append = append, - playNext = playNext, + insertionMode = insertionMode, autoPlay = autoPlay, shuffle = shuffle, playlistName = null, fragment = fragment ) + // Play Now doesn't get a Toast :) - if (playNext) { - successString = fragment.resources.getQuantityString( - R.plurals.select_album_n_songs_play_next, - songs.size, - songs.size - ) - } else if (append) { - successString = fragment.resources.getQuantityString( - R.plurals.select_album_n_songs_added, - songs.size, - songs.size - ) + successString = when (insertionMode) { + MediaPlayerManager.InsertionMode.AFTER_CURRENT -> + fragment.resources.getQuantityString( + R.plurals.n_songs_added_after_current, + songs.size, + songs.size + ) + MediaPlayerManager.InsertionMode.APPEND -> + fragment.resources.getQuantityString( + R.plurals.n_songs_added_to_end, + songs.size, + songs.size + ) + else -> null } } }) { successString } @@ -146,8 +147,7 @@ class DownloadHandler( fun addTracksToMediaController( songs: List, - append: Boolean, - playNext: Boolean, + insertionMode: MediaPlayerManager.InsertionMode, autoPlay: Boolean, shuffle: Boolean = false, playlistName: String? = null, @@ -157,12 +157,6 @@ class DownloadHandler( networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - val insertionMode = when { - append -> MediaPlayerManager.InsertionMode.APPEND - playNext -> MediaPlayerManager.InsertionMode.AFTER_CURRENT - else -> MediaPlayerManager.InsertionMode.CLEAR - } - if (playlistName != null) { mediaPlayerManager.suggestedPlaylistName = playlistName } @@ -173,7 +167,10 @@ class DownloadHandler( shuffle, insertionMode ) - if (Settings.shouldTransitionOnPlayback && (!append || autoPlay)) { + + if (Settings.shouldTransitionOnPlayback && + insertionMode == MediaPlayerManager.InsertionMode.CLEAR + ) { fragment.findNavController().popBackStack(R.id.playerFragment, true) fragment.findNavController().navigate(R.id.playerFragment) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaItemConverter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaItemConverter.kt index f7eebd3e..e05957ed 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaItemConverter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaItemConverter.kt @@ -14,7 +14,8 @@ import androidx.core.net.toUri import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_MIXED +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC import androidx.media3.common.StarRating import java.text.DateFormat import java.text.ParseException @@ -22,7 +23,7 @@ import java.util.Date import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.provider.AlbumArtContentProvider -// Copied from androidx.media.utils.MediaConstants in order to avoid importing a whole dependecy +// Copied from androidx.media.utils.MediaConstants in order to avoid importing a whole dependency // for a single string value private const val DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE = "android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT" @@ -76,15 +77,16 @@ fun Track.toMediaItem( title = title ?: "", mediaId = mediaId, isPlayable = !isDirectory, - folderType = if (isDirectory) MediaMetadata.FOLDER_TYPE_TITLES - else MediaMetadata.FOLDER_TYPE_NONE, + isBrowsable = isDirectory, album = album, artist = artist, genre = genre, sourceUri = uri.toUri(), imageUri = artworkUri, starred = starred, - group = null + group = null, + mediaType = if (isDirectory) MEDIA_TYPE_FOLDER_MIXED + else MEDIA_TYPE_MUSIC ) val metadataBuilder = mediaItem.mediaMetadata.buildUpon() @@ -204,14 +206,6 @@ private fun safeParseDate(created: String?): Date? { } else null } -fun MediaItem.setPin(pin: Boolean) { - this.mediaMetadata.extras?.putBoolean("pin", pin) -} - -fun MediaItem.shouldBePinned(): Boolean { - return this.mediaMetadata.extras?.getBoolean("pin") ?: false -} - /** * Build a new MediaItem from a list of attributes. * Especially useful to create folder entries in the Auto interface. @@ -222,7 +216,7 @@ fun buildMediaItem( title: String, mediaId: String, isPlayable: Boolean, - folderType: @MediaMetadata.FolderType Int, + isBrowsable: Boolean = false, album: String? = null, artist: String? = null, genre: String? = null, @@ -241,17 +235,13 @@ fun buildMediaItem( .setAlbumArtist(artist) .setGenre(genre) .setUserRating(HeartRating(starred)) - .setFolderType(folderType) + .setIsBrowsable(isBrowsable) .setIsPlayable(isPlayable) if (imageUri != null) { metadataBuilder.setArtworkUri(imageUri) } - if (folderType > FOLDER_TYPE_NONE) { - metadataBuilder.setIsBrowsable(true) - } - if (mediaType != null) { metadataBuilder.setMediaType(mediaType) } diff --git a/ultrasonic/src/main/res/values-cs/strings.xml b/ultrasonic/src/main/res/values-cs/strings.xml index 2bcf7604..140946b6 100644 --- a/ultrasonic/src/main/res/values-cs/strings.xml +++ b/ultrasonic/src/main/res/values-cs/strings.xml @@ -318,7 +318,7 @@ Posunout níž Ověření Rozšířené možnosti - + %d skladba %d skladby %d skladeb diff --git a/ultrasonic/src/main/res/values-de/strings.xml b/ultrasonic/src/main/res/values-de/strings.xml index b60c265f..c73cc34b 100644 --- a/ultrasonic/src/main/res/values-de/strings.xml +++ b/ultrasonic/src/main/res/values-de/strings.xml @@ -383,31 +383,31 @@ Demo Server Website besuchen Einen Fehler melden - + %d Titel %d Titel - + %d Titel zum Anheften ausgewählt %d Titel zum Anheften ausgewählt - + %d Titel zum herunterladen ausgewählt %d Titel zum herunterladen ausgewählt - + %d Titel losgelöst %d Titel losgelöst - + %d Titel gelöscht %d Titel gelöscht - + %d Titel am Ende hinzugefügt %d Titel am Ende hinzugefügt - + %d Titel nach aktuellen Titel hinzugefügt %d Titel nach aktuellen Titel hinzugefügt diff --git a/ultrasonic/src/main/res/values-es/strings.xml b/ultrasonic/src/main/res/values-es/strings.xml index 9d4d3466..6584faf7 100644 --- a/ultrasonic/src/main/res/values-es/strings.xml +++ b/ultrasonic/src/main/res/values-es/strings.xml @@ -390,37 +390,37 @@ Visitar la página web Informar de un error Ultrasonic es un cliente Android de streaming de música gratuito y de código abierto para servidores compatibles con la API de Subsonic (versión 1.7.0 o superior).\n\nCon Ultrasonic puede transmitir o descargar fácilmente música desde su ordenador de casa a su teléfono Android utilizando su servidor multimedia compatible con Subsonic. El software del servidor Subsonic requiere una configuración adicional aparte de Ultrasonic.\n\nPor defecto, Ultrasonic no está configurado. Una vez que hayas configurado tu propio servidor, cambia la configuración del servidor para que se conecte a tu propio ordenador. - + %d canción %d canciones %d canciones - + %d canción seleccionada para ser anclada %d canciones seleccionadas para ser ancladas %d canciones seleccionadas para ser ancladas - + %d canción seleccionada para ser descargada %d canciones seleccionadas para ser descargadas %d canciones seleccionadas para ser descargadas - + %d canción desanclada %d canciones desancladas %d canciones desancladas - + %d canción eliminada %d canciones eliminadas %d canciones eliminadas - + %d canción añadida al final de la cola de reproducción %d canciones añadidas al final de la cola de reproducción %d canciones añadidas al final de la cola de reproducción - + %d canción insertada después de la canción actual %d canciones insertadas después de la canción actual %d canciones insertadas después de la canción actual diff --git a/ultrasonic/src/main/res/values-fr/strings.xml b/ultrasonic/src/main/res/values-fr/strings.xml index 73226555..0f2f8e74 100644 --- a/ultrasonic/src/main/res/values-fr/strings.xml +++ b/ultrasonic/src/main/res/values-fr/strings.xml @@ -372,7 +372,7 @@ Visiter la page web Signaler un bug Ultrasonic est un client Android de streaming musical gratuit et open-source pour les serveurs compatibles avec l\'API Subsonic (version 1.7.0 ou supérieure). Avec Ultrasonic, vous pouvez facilement diffuser ou télécharger de la musique depuis votre ordinateur personnel vers votre téléphone Android en utilisant votre serveur multimédia compatible Subsonic. Le logiciel du serveur Subsonic nécessite une configuration supplémentaire distincte d\'Ultrasonic. Par défaut, Ultrasonic n\'est pas configuré. Une fois que vous avez mis en place votre propre serveur, veuillez modifier la configuration du serveur afin qu\'il se connecte à votre ordinateur. - + %d titre %d titres %d titres @@ -400,12 +400,12 @@ 50 morceaux Image d\'avatar Jour et nuit - + %d morceau ajouté à la file d\'attente de fin de lecture "%d morceaux ajoutés à la file d\'attente de fin de lecture" %d morceaux ajoutés à la file d\'attente de fin de lecture - + %d morceau supprimé %d morceaux supprimés %d morceaux supprimés @@ -429,22 +429,22 @@ Combien de chansons peuvent être téléchargées en parallèle Afficher plus de détails sur la chanson dans la lecture en cours (genre, année, débit) Liste - + %d chanson sélectionnée pour téléchargement %d chansons sélectionnées pour téléchargement %d chansons sélectionnées pour téléchargement - + %d chanson désépinglée %d chansons désépinglées %d chansons désépinglées - + %d chanson insérée après la chanson en cours %d chansons insérées après la chanson en cours %d chansons insérées après la chanson en cours - + %d chanson sélectionnée à épingler %d chansons sélectionnées à épingler %d chansons sélectionnées à épingler diff --git a/ultrasonic/src/main/res/values-gl/strings.xml b/ultrasonic/src/main/res/values-gl/strings.xml index 36cb490a..54b09230 100644 --- a/ultrasonic/src/main/res/values-gl/strings.xml +++ b/ultrasonic/src/main/res/values-gl/strings.xml @@ -51,12 +51,12 @@ Reproducir última Ancorar Mostra un cadro de diálogo de confirmación antes de eliminar ou desancorar as cancións - + %d canción seleccionada para ser ancorada %d cancións seleccionadas para ser ancoradas Tasa de bits máxima: ao fixar unha canción de forma permanente - + %d canción desancorada %d cancións desancoradas diff --git a/ultrasonic/src/main/res/values-hu/strings.xml b/ultrasonic/src/main/res/values-hu/strings.xml index 853d1e7f..cca3e6db 100644 --- a/ultrasonic/src/main/res/values-hu/strings.xml +++ b/ultrasonic/src/main/res/values-hu/strings.xml @@ -326,7 +326,7 @@ Lejjebb mozgat Bejelentkezés Haladó beállítások - + %d dal %d dal diff --git a/ultrasonic/src/main/res/values-ja/strings.xml b/ultrasonic/src/main/res/values-ja/strings.xml index d09880f5..51274e09 100644 --- a/ultrasonic/src/main/res/values-ja/strings.xml +++ b/ultrasonic/src/main/res/values-ja/strings.xml @@ -302,25 +302,25 @@ デモサーバー Webページにアクセス バグを報告 - + %d 曲 - + %d 曲がダウンロード選択されました - + %d 曲が固定解除されました - + %d 曲が固定されるよう選択されました - + %d 曲が削除されました - + %d 曲が再生キューの末尾に追加されました - + %d 曲が現在再生中の曲の次に追加されました 一般APIエラー: %1$s diff --git a/ultrasonic/src/main/res/values-nb-rNO/strings.xml b/ultrasonic/src/main/res/values-nb-rNO/strings.xml index 86e58df5..7a2c4406 100644 --- a/ultrasonic/src/main/res/values-nb-rNO/strings.xml +++ b/ultrasonic/src/main/res/values-nb-rNO/strings.xml @@ -123,11 +123,11 @@ Sett delingsinnstillinger Delinger Veksle spilleliste - + %d spor lagt til etter nåværende spor %d spor lagt til etter nåværende spor - + %d spor lagt til på slutten av spillekøen %d spor lagt til på slutten av spillekøen @@ -290,19 +290,19 @@ Skriv avlusningslogg til fil Rapporter en feil Generisk API-feil: %1$s - + %d spor %d spor - + %d spor å feste %d spor å feste - + %d spor løsnet %d spor løsnet - + %d spor slettet %d spor løsnet @@ -446,7 +446,7 @@ Omslag Støttede funksjoner Jukebox - + %d spor valgt for nedlasting %d spor valgt for nedlasting diff --git a/ultrasonic/src/main/res/values-nl/strings.xml b/ultrasonic/src/main/res/values-nl/strings.xml index 2fea5e44..af539bfd 100644 --- a/ultrasonic/src/main/res/values-nl/strings.xml +++ b/ultrasonic/src/main/res/values-nl/strings.xml @@ -391,31 +391,31 @@ Website openen Bug melden Ultrasonic is een gratis, open source muziekstreamingclient voor Android, die gebruikmaakt van servers die compatibel zijn met de Subsonic-api (versie 1.7.0 of hoger).\n\nMet Ultrasonic kun je eenvoudig muziek streamen of downloaden van je computer naar je Android-telefoon met behulp van een met Subsonic compatibele mediaserver. Let op: de Subsonic-serversoftware vereist aanvullende configuratie.\n\nStandaard is Ultrasonic niet ingesteld. Zet je eigen server op en wijzig de serverconfiguratie in die van je eigen. - + %d nummer %d nummers - + %d vast te maken nummer geselecteerd %d vast te maken nummers geselecteerd - + %d te downloaden nummer geselecteerd %d te downloaden nummers geselecteerd - + %d nummer losgemaakt %d nummers losgemaakt - + %d nummer verwijderd %d nummers verwijderd - + %d nummer toegevoegd aan het einde van afspeelwachtrij %d nummers toegevoegd aan het einde van afspeelwachtrij - + %d nummer ingevoegd na het huidige nummer %d nummers ingevoegd na het huidige nummer diff --git a/ultrasonic/src/main/res/values-pl/strings.xml b/ultrasonic/src/main/res/values-pl/strings.xml index 2e87a7ad..f62deae6 100644 --- a/ultrasonic/src/main/res/values-pl/strings.xml +++ b/ultrasonic/src/main/res/values-pl/strings.xml @@ -307,7 +307,7 @@ Przesuń się w dół Authentication Ustawienia zaawansowane - + %d utwór %d utwory %d utworów @@ -356,7 +356,7 @@ Węgierski Zapisanych jest %1$s plików z logami, które zajmują ~%2$s MB miejsca w katalogu %3$s. Czy chcesz je zachować\? Poprzednie - + Usunięto %d utwór Usunięto %d utwory Usunięto %d utworów @@ -403,20 +403,20 @@ Pobieraj tylko przez Wi-Fi Zawsze pytaj o opis i czas wygaśnięcia podczas tworzenia udostępnienia na serwerze Usuń pliki - + %d utwór zaznaczony do przypięcia %d utwory zaznaczone do przypięcia %d utworów zaznaczonych do przypięcia %d utworów zaznaczonych do przypięcia - + %d utworów zaznaczonych do pobrania %d utwory zaznaczone do pobrania %d utworów zaznaczonych do pobrania %d utworów zaznaczonych do pobrania Odwiedź stronę internetową - + Odpięto %d utwór Odpięto %d utwory Odpięto %d utworów @@ -446,13 +446,13 @@ Lista Okładka Spróbuj odtworzyć pliki multimedialne za pomocą układu dekodującego w telefonie. Może to zmniejszyć zużycie baterii! - + Dodano %d utwór na koniec kolejki odtwarzania Dodano %d utwory na koniec kolejki odtwarzania Dodano %d utworów na koniec kolejki odtwarzania Dodano %d utworów na koniec kolejki odtwarzania - + Wstawiono %d utwór po bieżącym utworze Wstawiono %d utwory po bieżącym utworze Wstawiono %d utworów po bieżącym utworze diff --git a/ultrasonic/src/main/res/values-pt-rBR/strings.xml b/ultrasonic/src/main/res/values-pt-rBR/strings.xml index 96691401..70a42293 100644 --- a/ultrasonic/src/main/res/values-pt-rBR/strings.xml +++ b/ultrasonic/src/main/res/values-pt-rBR/strings.xml @@ -388,37 +388,37 @@ Visitar a página web Reportar um erro Ultrasonic é um cliente gratuito e open-source para Android de streaming de música para API de servidores compatíveis com Subsonic (version 1.7.0 ou maior).\n\nCom Ultrasonic você pode facilmente reproduzir online ou baixar música de seu computador doméstico para seu telefone Android usando um servidor de mídia compatível com Subsonic. O software do servidor Subsonic necessita uma configuração independente do Ultrasonic.\n\nPor padrão, o servidor Ultrasonic não é configurado. Uma vez que você configurou seu próprio servidor, altere a configuração no Ultrasonic para poder conectá-lo. - + %d música %d músicas %d músicas - + %d música selecionada para ser fixada %d músicas selecionadas para serem fixadas %d músicas selecionadas para serem fixadas - + %d música selecionada para ser baixada %d músicas selecionadas para serem baixadas %d músicas selecionadas para serem baixadas - + %d música desfixada %d músicas desfixadas %d músicas desfixadas - + %d música excluída %d músicas excluídas %d músicas excluídas - + %d música adicionada ao final da playlist %d músicas adicionadas ao final da playlist %d músicas adicionadas ao final da playlist - + %d música adicionada após a atual %d músicas adicionadas após a atual %d músicas adicionadas após a atual diff --git a/ultrasonic/src/main/res/values-pt/strings.xml b/ultrasonic/src/main/res/values-pt/strings.xml index e0cf7285..834ea509 100644 --- a/ultrasonic/src/main/res/values-pt/strings.xml +++ b/ultrasonic/src/main/res/values-pt/strings.xml @@ -307,7 +307,7 @@ Move down Authentication Configurações avançadas - + %d música %d músicas %d músicas @@ -330,22 +330,22 @@ Servidor Demonstração Visitar a página web Reportar um erro - + %d música selecionada para ser fixada %d músicas selecionadas para serem fixadas %d músicas selecionadas para serem fixadas - + %d música desfixada %d músicas desfixadas %d músicas desfixadas - + %d música excluída %d músicas excluídas %d músicas excluídas - + %d música adicionada ao final da playlist %d músicas adicionadas ao final da playlist %d músicas adicionadas ao final da playlist @@ -447,12 +447,12 @@ \nCom Ultrasonic, pode facilmente reproduzir online ou descarregar música do seu computador doméstico para o seu telefone Android usando um servidor de mídia compatível com Subsonic. O software do servidor Subsonic necessita uma configuração independente do Ultrasonic. \n \nPor padrão, o servidor Ultrasonic não é configurado. Uma vez que configurou o seu próprio servidor, altere a configuração no Ultrasonic para poder conectá-lo. - + %d música selecionada a ser descarregada %d músicas selecionadas para serem baixadas %d músicas selecionadas para serem baixadas - + %d música adicionada após a atual %d músicas adicionadas após a atual %d músicas adicionadas após a atual diff --git a/ultrasonic/src/main/res/values-ru/strings.xml b/ultrasonic/src/main/res/values-ru/strings.xml index 0d9e5ff4..0c750c2f 100644 --- a/ultrasonic/src/main/res/values-ru/strings.xml +++ b/ultrasonic/src/main/res/values-ru/strings.xml @@ -347,7 +347,7 @@ Одна или несколько функций были отключены, потому что сервер их не поддерживает.\nВы можете запустить этот тест снова в любое время. Демо-сервер - + %d песня %d песни %d песен diff --git a/ultrasonic/src/main/res/values-zh-rCN/strings.xml b/ultrasonic/src/main/res/values-zh-rCN/strings.xml index f704d2a1..2410948c 100644 --- a/ultrasonic/src/main/res/values-zh-rCN/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rCN/strings.xml @@ -368,25 +368,25 @@ \n通过使用 Ultrasonic 你可以轻松的从你的电脑上的 Subsonic 兼容服务端流式传输或者下载音乐。 Subsonic 服务端与 Ultrasonic 都需要额外的配置才可使用。 \n \n默认情况下,Ultrasonic 并未进行配置,当服务端配置完成后,请确保配置允许客户端连接到你的计算机。 - + %d 首曲目 - + 已选择 %d 首歌曲进行固定 - + 已选择要下载 %d 首歌曲 - + 已选择 %d 首歌曲取消固定 - + %d 首歌曲被删除 - + 已将 %d 首歌曲添加到播放队列的末尾 - + 在当前歌曲之后插入了 %d 首歌曲 diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 08db0901..04488224 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -402,31 +402,35 @@ https://ultrasonic.gitlab.io/ https://gitlab.com/ultrasonic/ultrasonic/issues - + %d song %d songs - + %d song selected to be pinned %d songs selected to be pinned - + %d song selected to be downloaded %d songs selected to be downloaded - + %d song unpinned %d songs unpinned - + %d song deleted %d songs deleted - + + %d song added to the play queue + %d songs added to the play queue + + %d song added to the end of play queue %d songs added to the end of play queue - + %d song inserted after current song %d songs inserted after current song