From 48820370984d9863e5778c531e10072875a582ac Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Mon, 24 Apr 2023 11:37:53 +0000 Subject: [PATCH 1/5] 433 --- fastlane/metadata/android/en-US/changelogs/116.txt | 8 ++++++++ ultrasonic/build.gradle | 4 ++-- .../org/moire/ultrasonic/adapters/HeaderViewBinder.kt | 7 +++++-- .../org/moire/ultrasonic/fragment/NowPlayingFragment.kt | 3 ++- 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/116.txt diff --git a/fastlane/metadata/android/en-US/changelogs/116.txt b/fastlane/metadata/android/en-US/changelogs/116.txt new file mode 100644 index 00000000..b71f8806 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/116.txt @@ -0,0 +1,8 @@ +Bug fixes +- Fix various crashes + +Changes since 4.2.0 +- #827: Make app full compliant Android Auto to publish in Play Store. +- #878: "Play shuffled" option for playlists always begins with the first track. +- #891: Dump config to log file when logging is enabled. +- #854: Remove Videos menu option for servers which don't support it. diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 081bbfd0..bfaafd03 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -9,8 +9,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 115 - versionName "4.3.2" + versionCode 116 + versionName "4.3.3" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk 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 1d0efedd..c2d25b0c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/HeaderViewBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/HeaderViewBinder.kt @@ -51,11 +51,14 @@ class HeaderViewBinder( val resources = context.resources val artworkSelection = random.nextInt(item.childCount) + val size = Util.getAlbumImageSize(context) imageLoaderProvider.executeOn { it.loadImage( - holder.coverArtView, item.entries[artworkSelection], false, - Util.getAlbumImageSize(context) + holder.coverArtView, + item.entries[artworkSelection], + false, + size ) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt index 9ca26871..7fb0c8c4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -96,13 +96,14 @@ class NowPlayingFragment : Fragment() { if (file != null) { val title = file.title val artist = file.artist + val size = getNotificationImageSize(requireContext()) imageLoaderProvider.executeOn { it.loadImage( nowPlayingAlbumArtImage, file, false, - getNotificationImageSize(requireContext()) + size ) } From 88364b15d629d001d5776132839c1bbf010cd2cd Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Sun, 7 May 2023 15:23:38 +0000 Subject: [PATCH 2/5] Release 4.3.4 --- .../metadata/android/en-US/changelogs/117.txt | 8 +++++ gradle/libs.versions.toml | 5 ++-- ultrasonic/build.gradle | 4 +-- .../moire/ultrasonic/view/GenreAdapter.java | 2 +- .../fragment/TrackCollectionFragment.kt | 2 ++ .../fragment/legacy/SelectGenreFragment.kt | 4 ++- .../ultrasonic/imageloader/ImageLoader.kt | 2 ++ .../ultrasonic/subsonic/DownloadHandler.kt | 16 +++++++++- .../org/moire/ultrasonic/util/Dialogs.kt | 21 +++++++++----- .../org/moire/ultrasonic/util/StorageFile.kt | 2 +- .../kotlin/org/moire/ultrasonic/util/Util.kt | 29 +++++++++++-------- 11 files changed, 68 insertions(+), 27 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/117.txt diff --git a/fastlane/metadata/android/en-US/changelogs/117.txt b/fastlane/metadata/android/en-US/changelogs/117.txt new file mode 100644 index 00000000..557e9b8b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/117.txt @@ -0,0 +1,8 @@ +Bug fixes +- Fix more exceptions + +Changes since 4.2.0 +- #827: Make app full compliant Android Auto to publish in Play Store. +- #878: "Play shuffled" option for playlists always begins with the first track. +- #891: Dump config to log file when logging is enabled. +- #854: Remove Videos menu option for servers which don't support it. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 716a1706..5661132f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ ktlint = "0.43.2" ktlintGradle = "11.3.1" detekt = "1.22.0" preferences = "1.2.0" -media3 = "1.0.0" +media3 = "1.0.1" androidSupport = "1.6.0" materialDesign = "1.8.0" @@ -23,7 +23,8 @@ viewModelKtx = "2.6.1" swipeRefresh = "1.1.0" retrofit = "2.9.0" -jackson = "2.14.2" +## 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.10.0" koin = "3.3.2" picasso = "2.8" diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index d89368b3..e367497a 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -9,8 +9,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 116 - versionName "4.3.3" + versionCode 117 + versionName "4.3.4" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java index 475bb602..eeb1c48b 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java @@ -46,7 +46,7 @@ public class GenreAdapter extends ArrayAdapter implements SectionIndexer private final Object[] sections; private final Integer[] positions; - public GenreAdapter(Context context, List genres) + public GenreAdapter(@NonNull Context context, List genres) { super(context, R.layout.list_item_generic, genres); 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 b67ec05b..6664f17e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -401,6 +401,8 @@ open class TrackCollectionFragment( ) { // We are coming back from unknown context // and need to ensure Main Thread in order to manipulate the UI + // If view is null, our view was disposed in the meantime + if (view == null) return viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { val multipleSelection = viewAdapter.hasMultipleSelection() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SelectGenreFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SelectGenreFragment.kt index 31790ef3..0a876b45 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SelectGenreFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SelectGenreFragment.kt @@ -102,7 +102,9 @@ class SelectGenreFragment : Fragment() { override fun done(result: List) { emptyView!!.isVisible = result.isEmpty() - genreListView!!.adapter = GenreAdapter(context, result) + if (context != null) { + genreListView!!.adapter = GenreAdapter(context!!, result) + } } } task.execute() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt index c814eeec..06bc30ff 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt @@ -240,6 +240,8 @@ class ImageLoader( } finally { inputStream.safeClose() } + } catch (all: Exception) { + Timber.w(all) } finally { cacheInProgress.remove(file)?.countDown() } 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 286ce8f2..ce14c811 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt @@ -7,11 +7,14 @@ package org.moire.ultrasonic.subsonic +import android.os.Handler +import android.os.Looper import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import java.util.Collections import java.util.LinkedList import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -28,6 +31,7 @@ import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator import org.moire.ultrasonic.util.InfoDialog import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util +import timber.log.Timber /** * Retrieves a list of songs and adds them to the now playing list @@ -39,6 +43,16 @@ class DownloadHandler( ) : CoroutineScope by CoroutineScope(Dispatchers.IO) { private val maxSongs = 500 + /** + * Exception Handler for Coroutines + */ + val exceptionHandler = CoroutineExceptionHandler { _, exception -> + Handler(Looper.getMainLooper()).post { + Timber.w(exception) + } + } + + // TODO: Use coroutine here (with proper exception handler) fun download( fragment: Fragment, append: Boolean, @@ -210,7 +224,7 @@ class DownloadHandler( isArtist: Boolean ) { // Launch the Job - val job = launch { + val job = launch(exceptionHandler) { val songs: MutableList = getTracksFromServer(isArtist, id, isDirectory, name, isShare) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Dialogs.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Dialogs.kt index a33dae82..7988dc3c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Dialogs.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Dialogs.kt @@ -10,7 +10,9 @@ package org.moire.ultrasonic.util import android.app.Activity import android.content.Context import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.lang.ref.WeakReference import org.moire.ultrasonic.R +import timber.log.Timber /* * InfoDialog can be used to show some information to the user. Typically it cannot be cancelled, @@ -19,24 +21,30 @@ import org.moire.ultrasonic.R open class InfoDialog( context: Context, message: CharSequence?, - private val activity: Activity? = null, + activity: Activity? = null, private val finishActivityOnClose: Boolean = false ) { - - open var builder: MaterialAlertDialogBuilder = Builder(activity ?: context, message) + private val activityRef: WeakReference = WeakReference(activity) + open var builder: MaterialAlertDialogBuilder = Builder(activityRef.get() ?: context, message) fun show() { builder.setOnCancelListener { if (finishActivityOnClose) { - activity!!.finish() + activityRef.get()?.finish() } } builder.setPositiveButton(R.string.common_ok) { _, _ -> if (finishActivityOnClose) { - activity!!.finish() + activityRef.get()?.finish() } } - builder.create().show() + + // If the app was put into the background in the meantime this would fail + try { + builder.create().show() + } catch (all: Exception) { + Timber.w(all, "Failed to create dialog") + } } class Builder(context: Context) : MaterialAlertDialogBuilder(context) { @@ -93,7 +101,6 @@ class ConfirmationDialog( activity: Activity? = null, finishActivityOnClose: Boolean = false ) : InfoDialog(context, message, activity, finishActivityOnClose) { - override var builder: MaterialAlertDialogBuilder = Builder(activity ?: context, message) class Builder(context: Context) : MaterialAlertDialogBuilder(context) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt index 09b12f3c..5d1eb983 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt @@ -273,7 +273,7 @@ class StorageFile( } private fun getStorageFileForParentDirectory(path: String): StorageFile? { - val parentPath = FileUtil.getParentPath(path)!! + val parentPath = FileUtil.getParentPath(path) ?: return null if (storageFilePathDictionary.containsKey(parentPath)) return storageFilePathDictionary[parentPath]!! if (notExistingPathDictionary.contains(parentPath)) return null diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index 6ba28594..80517991 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -133,19 +133,24 @@ object Util { @JvmStatic @SuppressLint("ShowToast") // Invalid warning fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) { - if (toast == null) { - toast = Toast.makeText( - context, - message, - if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG - ) - toast!!.setGravity(Gravity.CENTER, 0, 0) - } else { - toast!!.setText(message) - toast!!.duration = - if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG + // If called after doing some background processing, our context might have expired! + try { + if (toast == null) { + toast = Toast.makeText( + context, + message, + if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG + ) + toast!!.setGravity(Gravity.CENTER, 0, 0) + } else { + toast!!.setText(message) + toast!!.duration = + if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG + } + toast!!.show() + } catch (_: Exception) { + // Ignore } - toast!!.show() } /** From 537d65affc588fc679c9dfd2803c34685b5d1ace Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Mon, 15 May 2023 07:15:56 +0000 Subject: [PATCH 3/5] Release 4.4.0 --- build.gradle | 2 - core/subsonic-api/build.gradle | 1 - .../metadata/android/en-US/changelogs/119.txt | 10 + .../android/en-US/full_description.txt | 4 +- gradle.properties | 11 +- gradle/libs.versions.toml | 21 +- gradle/wrapper/gradle-wrapper.jar | Bin 61608 -> 62076 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 7 +- ultrasonic/build.gradle | 8 +- ultrasonic/src/main/AndroidManifest.xml | 1 - .../ultrasonic/adapters/AlbumRowDelegate.kt | 34 +- .../ultrasonic/adapters/TrackViewBinder.kt | 4 - .../ultrasonic/adapters/TrackViewHolder.kt | 54 +-- .../org/moire/ultrasonic/data/RatingUpdate.kt | 16 + .../ultrasonic/fragment/EntryListFragment.kt | 46 +-- .../ultrasonic/fragment/NowPlayingFragment.kt | 4 +- .../ultrasonic/fragment/PlayerFragment.kt | 218 +++++----- .../ultrasonic/fragment/SearchFragment.kt | 40 +- .../fragment/TrackCollectionFragment.kt | 132 +++--- .../fragment/legacy/PlaylistsFragment.kt | 43 +- .../fragment/legacy/SharesFragment.kt | 79 ++-- .../playback/AutoMediaBrowserCallback.kt | 31 +- .../ultrasonic/playback/PlaybackService.kt | 42 ++ .../service/MediaPlayerController.kt | 222 +++++++--- .../service/MediaPlayerLifecycleSupport.kt | 22 +- .../moire/ultrasonic/service/MusicService.kt | 4 +- .../moire/ultrasonic/service/RatingManager.kt | 87 ++++ .../org/moire/ultrasonic/service/RxBus.kt | 19 +- .../ultrasonic/subsonic/DownloadHandler.kt | 380 ++++++------------ .../ultrasonic/util/CoroutinePatterns.kt | 82 ++++ .../org/moire/ultrasonic/util/Settings.kt | 2 +- .../org/moire/ultrasonic/util/Storage.kt | 3 +- .../kotlin/org/moire/ultrasonic/util/Util.kt | 10 +- ultrasonic/src/main/res/menu/nowplaying.xml | 7 + ultrasonic/src/main/res/values-es/strings.xml | 4 +- ultrasonic/src/main/res/values-gl/strings.xml | 27 +- ultrasonic/src/main/res/values-pl/strings.xml | 186 +++++++-- .../src/main/res/values-zh-rCN/strings.xml | 106 ++--- .../src/main/res/values-zh-rTW/strings.xml | 109 ++++- ultrasonic/src/main/res/values/strings.xml | 4 +- 41 files changed, 1243 insertions(+), 841 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/119.txt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/RatingUpdate.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RatingManager.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt diff --git a/build.gradle b/build.gradle index a4f4bb4b..d9cf0212 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,6 @@ buildscript { google() mavenCentral() maven { url "https://plugins.gradle.org/m2/" } - maven { url 'https://jitpack.io' } } dependencies { classpath libs.gradle @@ -34,7 +33,6 @@ allprojects { repositories { mavenCentral() google() - maven { url 'https://jitpack.io' } } // Set Kotlin JVM target to the same for all subprojects diff --git a/core/subsonic-api/build.gradle b/core/subsonic-api/build.gradle index a70a11a2..9f044c21 100644 --- a/core/subsonic-api/build.gradle +++ b/core/subsonic-api/build.gradle @@ -13,7 +13,6 @@ dependencies { testImplementation libs.kotlinJunit testImplementation libs.mockito - testImplementation libs.mockitoInline testImplementation libs.mockitoKotlin testImplementation libs.kluent testImplementation libs.mockWebServer diff --git a/fastlane/metadata/android/en-US/changelogs/119.txt b/fastlane/metadata/android/en-US/changelogs/119.txt new file mode 100644 index 00000000..be4a20f9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/119.txt @@ -0,0 +1,10 @@ +Features: +- This releases focuses on shuffled playback. The view of the playlist will now present itself in the order it will actually play. You can toggle the shuffle mode to create a new order, while the past playback history will be preserved. +- Use Coroutines for triggering the download or playback of music through the context menus +- Enable Artists pictures by Default + +Bug fixes: +- Remove an unhelpful popup that "ID must be set" +- Shuffle mode doesn't always play all tracks +- Shuffle mode starts with the first track most of the time + diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index fcf6a32d..18d5f968 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -3,12 +3,12 @@ Ultrasonic is a Subsonic (and compatible servers) client to Android. You can use Main features: * Thin * Fast -* Dark and light theme +* Material theme with dark and light variants * Multiple server support * Offline Mode * Bookmarks * Playlists on server -* Ramdom play +* Random play * Jukebox mode * Server chat * And much more!!! diff --git a/gradle.properties b/gradle.properties index a7c0d4f8..7ab3793a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,10 +4,19 @@ org.gradle.configureondemand=true org.gradle.caching=true org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC - kotlin.incremental=true kotlin.caching.enabled=true kotlin.incremental.usePreciseJavaTracking=true android.useAndroidX=true android.enableJetifier=false + +# This properties enables transitive Resource classes, which decreases build time, +# but could lead to problems referencing Resources. Set them to false if needed. +android.nonTransitiveRClass=true +android.nonFinalResIds=true + +# This config was suggested by Android Studio to reduce build time +# It can be removed if it makes problems +org.gradle.unsafe.configuration-cache=true + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5661132f..dc011630 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] # You need to run ./gradlew wrapper after updating the version -gradle = "7.6" +gradle = "8.1.1" navigation = "2.5.3" -gradlePlugin = "7.4.2" +gradlePlugin = "8.0.1" androidxcore = "1.10.0" ktlint = "0.43.2" -ktlintGradle = "11.3.1" +ktlintGradle = "11.3.2" detekt = "1.22.0" preferences = "1.2.0" media3 = "1.0.1" @@ -16,9 +16,9 @@ materialDesign = "1.8.0" constraintLayout = "2.1.4" multidex = "2.0.1" room = "2.5.1" -kotlin = "1.8.20" -kotlinxCoroutines = "1.6.4" -kotlinxGuava = "1.6.4" +kotlin = "1.8.21" +kotlinxCoroutines = "1.7.0" +kotlinxGuava = "1.7.0" viewModelKtx = "2.6.1" swipeRefresh = "1.1.0" @@ -30,12 +30,12 @@ koin = "3.3.2" picasso = "2.8" junit4 = "4.13.2" -junit5 = "5.9.2" -mockito = "5.2.0" +junit5 = "5.9.3" +mockito = "5.3.1" mockitoKotlin = "4.1.0" -kluent = "1.72" +kluent = "1.73" apacheCodecs = "1.15" -robolectric = "4.9.2" +robolectric = "4.10.2" timber = "5.0.1" fastScroll = "2.0.1" colorPicker = "2.2.4" @@ -96,7 +96,6 @@ junitVintage = { module = "org.junit.vintage:junit-vintage-engine", v kotlinJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } mockitoKotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" } -mockitoInline = { module = "org.mockito:mockito-inline", version.ref = "mockito" } kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" } kluentAndroid = { module = "org.amshove.kluent:kluent-android", version.ref = "kluent" } mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba7710deaf9f98673a68957ea02138b60d0a..c1962a79e29d3e0ab67b14947c167a862655af9b 100644 GIT binary patch delta 8979 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 F{TFmrHLU;u delta 8469 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 - diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowDelegate.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowDelegate.kt index dfbce65a..c54cb32a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowDelegate.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowDelegate.kt @@ -15,17 +15,17 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.media3.common.HeartRating import androidx.recyclerview.widget.RecyclerView import com.drakeet.multitype.ItemViewDelegate import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R +import org.moire.ultrasonic.data.RatingUpdate import org.moire.ultrasonic.domain.Album -import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.util.LayoutType -import org.moire.ultrasonic.util.Settings.shouldUseId3Tags -import timber.log.Timber /** * Creates a Row in a RecyclerView which contains the details of an Album @@ -112,27 +112,13 @@ open class AlbumRowDelegate( private fun onStarClick(entry: Album, star: ImageView) { entry.starred = !entry.starred star.setImageResource(if (entry.starred) starDrawable else starHollowDrawable) - val musicService = getMusicService() - Thread { - val useId3 = shouldUseId3Tags - try { - if (entry.starred) { - musicService.star( - if (!useId3) entry.id else null, - if (useId3) entry.id else null, - null - ) - } else { - musicService.unstar( - if (!useId3) entry.id else null, - if (useId3) entry.id else null, - null - ) - } - } catch (all: Exception) { - Timber.e(all) - } - }.start() + + RxBus.ratingSubmitter.onNext( + RatingUpdate( + entry.id, + HeartRating(entry.starred) + ) + ) } override fun onCreateViewHolder(context: Context, parent: ViewGroup): ListViewHolder { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt index 62612082..bca305aa 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt @@ -62,8 +62,6 @@ class TrackViewBinder( diffAdapter.isSelected(item.longId) ) - // Timber.v("Setting listeners") - holder.itemView.setOnLongClickListener { if (onContextMenuClick != null) { val popup = createContextMenu(holder.itemView, track) @@ -116,8 +114,6 @@ class TrackViewBinder( if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus } - - // Timber.v("Setting listeners done") } override fun onViewRecycled(holder: TrackViewHolder) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 630c00cf..79b74069 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.MutableLiveData +import androidx.media3.common.HeartRating import androidx.recyclerview.widget.RecyclerView import com.google.android.material.progressindicator.CircularProgressIndicator import io.reactivex.rxjava3.disposables.CompositeDisposable @@ -19,10 +20,10 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.moire.ultrasonic.R import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.data.RatingUpdate import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.service.DownloadService import org.moire.ultrasonic.service.DownloadState -import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.util.Settings @@ -81,7 +82,6 @@ class TrackViewHolder(val view: View) : draggable: Boolean, isSelected: Boolean = false ) { - // Timber.v("Setting song") val useFiveStarRating = Settings.useFiveStarRating entry = song @@ -118,9 +118,9 @@ class TrackViewHolder(val view: View) : } if (useFiveStarRating) { - setFiveStars(entry?.userRating ?: 0) + updateFiveStars(entry?.userRating ?: 0) } else { - setSingleStar(entry!!.starred) + updateSingleStar(entry!!.starred) } if (song.isVideo) { @@ -131,7 +131,7 @@ class TrackViewHolder(val view: View) : // Create new Disposable for the new Subscriptions rxBusSubscription = CompositeDisposable() rxBusSubscription!! += RxBus.playerStateObservable.subscribe { - setPlayIcon(it.index == bindingAdapterPosition && it.track?.id == song.id) + setPlayIcon(it.track?.id == song.id && it.index == bindingAdapterPosition) } rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe { @@ -165,48 +165,32 @@ class TrackViewHolder(val view: View) : } } - private fun setupStarButtons(song: Track, useFiveStarRating: Boolean) { + private fun setupStarButtons(track: Track, useFiveStarRating: Boolean) { if (useFiveStarRating) { // Hide single star star.isGone = true rating.isVisible = true - val rating = if (song.userRating == null) 0 else song.userRating!! - setFiveStars(rating) + val rating = if (track.userRating == null) 0 else track.userRating!! + updateFiveStars(rating) + + // Five star rating has no click handler because in the + // track view theres not enough space } else { star.isVisible = true rating.isGone = true - setSingleStar(song.starred) + updateSingleStar(track.starred) star.setOnClickListener { - val isStarred = song.starred - val id = song.id - - if (!isStarred) { - star.setImageResource(R.drawable.ic_star_full) - song.starred = true - } else { - star.setImageResource(R.drawable.ic_star_hollow) - song.starred = false - } - - // Should this be done here ? - Thread { - val musicService = MusicServiceFactory.getMusicService() - try { - if (!isStarred) { - musicService.star(id, null, null) - } else { - musicService.unstar(id, null, null) - } - } catch (all: Exception) { - Timber.e(all) - } - }.start() + track.starred = !track.starred + updateSingleStar(track.starred) + RxBus.ratingSubmitter.onNext( + RatingUpdate(track.id, HeartRating(track.starred)) + ) } } } @Suppress("MagicNumber") - private fun setFiveStars(rating: Int) { + private fun updateFiveStars(rating: Int) { fiveStar1.setImageResource( if (rating > 0) R.drawable.ic_star_full else R.drawable.ic_star_hollow ) @@ -224,7 +208,7 @@ class TrackViewHolder(val view: View) : ) } - private fun setSingleStar(starred: Boolean) { + private fun updateSingleStar(starred: Boolean) { if (starred) { star.setImageResource(R.drawable.ic_star_full) } else { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/RatingUpdate.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/RatingUpdate.kt new file mode 100644 index 00000000..93faedee --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/RatingUpdate.kt @@ -0,0 +1,16 @@ +/* + * RatingUpdate.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.data + +import androidx.media3.common.Rating + +data class RatingUpdate( + val id: String, + val rating: Rating, + val success: Boolean? = null +) 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 8b0f1241..80a78416 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt @@ -21,6 +21,7 @@ import org.moire.ultrasonic.domain.GenericEntry import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign +import org.moire.ultrasonic.subsonic.DownloadAction import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.util.Settings @@ -129,81 +130,54 @@ abstract class EntryListFragment : MultiListFragment() { ): Boolean { when (menuItem.itemId) { R.id.menu_play_now -> - downloadHandler.downloadRecursively( + downloadHandler.fetchTracksAndAddToController( fragment, item.id, - save = false, append = false, autoPlay = true, shuffle = false, - background = false, playNext = false, - unpin = false, isArtist = isArtist ) R.id.menu_play_next -> - downloadHandler.downloadRecursively( + downloadHandler.fetchTracksAndAddToController( fragment, item.id, - save = false, append = false, autoPlay = true, shuffle = true, - background = false, playNext = true, - unpin = false, isArtist = isArtist ) R.id.menu_play_last -> - downloadHandler.downloadRecursively( + downloadHandler.fetchTracksAndAddToController( fragment, item.id, - save = false, append = true, autoPlay = false, shuffle = false, - background = false, playNext = false, - unpin = false, isArtist = isArtist ) R.id.menu_pin -> - downloadHandler.downloadRecursively( + downloadHandler.justDownload( + action = DownloadAction.PIN, fragment, item.id, - save = true, - append = true, - autoPlay = false, - shuffle = false, - background = false, - playNext = false, - unpin = false, isArtist = isArtist ) R.id.menu_unpin -> - downloadHandler.downloadRecursively( + downloadHandler.justDownload( + action = DownloadAction.UNPIN, fragment, item.id, - save = false, - append = false, - autoPlay = false, - shuffle = false, - background = false, - playNext = false, - unpin = true, isArtist = isArtist ) R.id.menu_download -> - downloadHandler.downloadRecursively( + downloadHandler.justDownload( + action = DownloadAction.DOWNLOAD, fragment, item.id, - save = false, - append = false, - autoPlay = false, - shuffle = false, - background = true, - playNext = false, - unpin = false, isArtist = isArtist ) else -> return false diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt index 7fb0c8c4..eb192af7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -149,10 +149,10 @@ class NowPlayingFragment : Fragment() { if (abs(deltaX) > MIN_DISTANCE) { // left or right if (deltaX < 0) { - mediaPlayerController.previous() + mediaPlayerController.seekToPrevious() } if (deltaX > 0) { - mediaPlayerController.next() + mediaPlayerController.seekToNext() } } else if (abs(deltaY) > MIN_DISTANCE) { if (deltaY < 0) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 4857f708..2dd60683 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -35,11 +35,15 @@ import android.widget.TextView import android.widget.Toast import android.widget.ViewFlipper import androidx.core.content.res.ResourcesCompat +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem import androidx.media3.common.Player -import androidx.media3.session.SessionResult +import androidx.media3.common.StarRating import androidx.navigation.Navigation import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.ItemTouchHelper @@ -49,8 +53,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton -import com.google.common.util.concurrent.FutureCallback -import com.google.common.util.concurrent.Futures import io.reactivex.rxjava3.disposables.CompositeDisposable import java.text.DateFormat import java.text.SimpleDateFormat @@ -76,6 +78,7 @@ import org.moire.ultrasonic.adapters.TrackViewBinder import org.moire.ultrasonic.api.subsonic.models.AlbumListType import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline +import org.moire.ultrasonic.data.RatingUpdate import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.Track @@ -98,7 +101,7 @@ import timber.log.Timber /** * Contains the Music Player screen of Ultrasonic with playback controls and the playlist - * TODO: Add timeline lister -> updateProgressBar(). + * */ @Suppress("LargeClass", "TooManyFunctions", "MagicNumber") class PlayerFragment : @@ -132,7 +135,6 @@ class PlayerFragment : // Views and UI Elements private lateinit var playlistNameView: EditText - private lateinit var starMenuItem: MenuItem private lateinit var fiveStar1ImageView: ImageView private lateinit var fiveStar2ImageView: ImageView private lateinit var fiveStar3ImageView: ImageView @@ -154,6 +156,8 @@ class PlayerFragment : private lateinit var pauseButton: View private lateinit var stopButton: View private lateinit var playButton: View + private lateinit var previousButton: MaterialButton + private lateinit var nextButton: MaterialButton private lateinit var shuffleButton: View private lateinit var repeatButton: MaterialButton private lateinit var progressBar: SeekBar @@ -196,6 +200,8 @@ class PlayerFragment : pauseButton = view.findViewById(R.id.button_pause) stopButton = view.findViewById(R.id.button_stop) playButton = view.findViewById(R.id.button_start) + nextButton = view.findViewById(R.id.button_next) + previousButton = view.findViewById(R.id.button_previous) repeatButton = view.findViewById(R.id.button_repeat) fiveStar1ImageView = view.findViewById(R.id.song_five_star_1) fiveStar2ImageView = view.findViewById(R.id.song_five_star_2) @@ -226,7 +232,13 @@ class PlayerFragment : height = size.y } - setHasOptionsMenu(true) + // Register our options menu + (requireActivity() as MenuHost).addMenuProvider( + menuProvider, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + useFiveStarRating = Settings.useFiveStarRating swipeDistance = (width + height) * PERCENTAGE_OF_SCREEN_FOR_SWIPE / 100 swipeVelocity = swipeDistance @@ -259,9 +271,7 @@ class PlayerFragment : previousButton.setOnClickListener { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() launch(CommunicationError.getHandler(context)) { - mediaPlayerController.previous() - onCurrentChanged() - onSliderProgressChanged() + mediaPlayerController.seekToPrevious() } } @@ -272,9 +282,7 @@ class PlayerFragment : nextButton.setOnClickListener { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() launch(CommunicationError.getHandler(context)) { - mediaPlayerController.next() - onCurrentChanged() - onSliderProgressChanged() + mediaPlayerController.seekToNext() } } @@ -285,16 +293,12 @@ class PlayerFragment : pauseButton.setOnClickListener { launch(CommunicationError.getHandler(context)) { mediaPlayerController.pause() - onCurrentChanged() - onSliderProgressChanged() } } stopButton.setOnClickListener { launch(CommunicationError.getHandler(context)) { mediaPlayerController.reset() - onCurrentChanged() - onSliderProgressChanged() } } @@ -304,8 +308,6 @@ class PlayerFragment : launch(CommunicationError.getHandler(context)) { mediaPlayerController.play() - onCurrentChanged() - onSliderProgressChanged() } } @@ -342,7 +344,6 @@ class PlayerFragment : override fun onStopTrackingTouch(seekBar: SeekBar) { launch(CommunicationError.getHandler(context)) { mediaPlayerController.seekTo(progressBar.progress) - onSliderProgressChanged() } } @@ -367,11 +368,13 @@ class PlayerFragment : // Observe playlist changes and update the UI rxBusSubscription += RxBus.playlistObservable.subscribe { onPlaylistChanged() - onSliderProgressChanged() + updateSeekBar() } rxBusSubscription += RxBus.playerStateObservable.subscribe { update() + updateTitle(it.state) + updateButtonStates(it.state) } // Query the Jukebox state in an IO Context @@ -432,7 +435,7 @@ class PlayerFragment : } else { // Download list and Album art must be updated when resumed onPlaylistChanged() - onCurrentChanged() + onTrackChanged() } val handler = Handler(Looper.getMainLooper()) @@ -472,23 +475,55 @@ class PlayerFragment : super.onDestroyView() } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.nowplaying, menu) - super.onCreateOptionsMenu(menu, inflater) + private val menuProvider: MenuProvider = object : MenuProvider { + override fun onPrepareMenu(menu: Menu) { + setupOptionsMenu(menu) + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.nowplaying, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return menuItemSelected(menuItem.itemId, currentSong) + } } @Suppress("ComplexMethod", "LongMethod", "NestedBlockDepth") - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) + fun setupOptionsMenu(menu: Menu) { + // Seems there is nothing like ViewBinding for Menus val screenOption = menu.findItem(R.id.menu_item_screen_on_off) + val goToAlbum = menu.findItem(R.id.menu_show_album) + val goToArtist = menu.findItem(R.id.menu_show_artist) val jukeboxOption = menu.findItem(R.id.menu_item_jukebox) val equalizerMenuItem = menu.findItem(R.id.menu_item_equalizer) val shareMenuItem = menu.findItem(R.id.menu_item_share) val shareSongMenuItem = menu.findItem(R.id.menu_item_share_song) - starMenuItem = menu.findItem(R.id.menu_item_star) + val starMenuItem = menu.findItem(R.id.menu_item_star) val bookmarkMenuItem = menu.findItem(R.id.menu_item_bookmark_set) val bookmarkRemoveMenuItem = menu.findItem(R.id.menu_item_bookmark_delete) + // Listen to rating changes and update the UI + rxBusSubscription += RxBus.ratingPublishedObservable.subscribe { update -> + + // Ignore updates which are not for the current song + if (update.id != currentSong?.id) return@subscribe + + // Ensure UI thread + launch { + if (update.success == true && update.rating is HeartRating) { + if (update.rating.isHeart) { + starMenuItem.setIcon(fullStar) + } else { + starMenuItem.setIcon(hollowStar) + } + } else if (update.success == false) { + Toast.makeText(context, "Setting rating failed", Toast.LENGTH_SHORT) + .show() + } + } + } + if (isOffline()) { if (shareMenuItem != null) { shareMenuItem.isVisible = false @@ -505,6 +540,7 @@ class PlayerFragment : equalizerMenuItem.isEnabled = isEqualizerAvailable equalizerMenuItem.isVisible = isEqualizerAvailable } + val mediaPlayerController = mediaPlayerController val track = mediaPlayerController.currentMediaItem?.toTrack() @@ -517,9 +553,13 @@ class PlayerFragment : if (currentSong != null) { starMenuItem.setIcon(if (currentSong!!.starred) fullStar else hollowStar) shareSongMenuItem.isVisible = true + goToAlbum.isVisible = true + goToArtist.isVisible = true } else { starMenuItem.setIcon(hollowStar) shareSongMenuItem.isVisible = false + goToAlbum.isVisible = false + goToArtist.isVisible = false } if (mediaPlayerController.keepScreenOn) { @@ -560,10 +600,6 @@ class PlayerFragment : return popup } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return menuItemSelected(item.itemId, currentSong) || super.onOptionsItemSelected(item) - } - private fun onContextMenuItemSelected( menuItem: MenuItem, item: MusicDirectory.Child @@ -660,31 +696,11 @@ class PlayerFragment : } R.id.menu_item_star -> { if (track == null) return true + track.starred = !track.starred - val isStarred = track.starred - - mediaPlayerController.toggleSongStarred()?.let { - Futures.addCallback( - it, - object : FutureCallback { - override fun onSuccess(result: SessionResult?) { - if (isStarred) { - starMenuItem.setIcon(hollowStar) - track.starred = false - } else { - starMenuItem.setIcon(fullStar) - track.starred = true - } - } - - override fun onFailure(t: Throwable) { - Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT) - .show() - } - }, - this.executorService - ) - } + RxBus.ratingSubmitter.onNext( + RatingUpdate(track.id, HeartRating(track.starred)) + ) return true } @@ -764,10 +780,9 @@ class PlayerFragment : if (cancel?.isCancellationRequested == true) return val mediaPlayerController = mediaPlayerController if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) { - onCurrentChanged() + onTrackChanged() } - onSliderProgressChanged() - requireActivity().invalidateOptionsMenu() + updateSeekBar() } private fun savePlaylistInBackground(playlistName: String) { @@ -827,12 +842,9 @@ class PlayerFragment : } // Create listener - val clickHandler: ((Track, Int) -> Unit) = { _, pos -> - mediaPlayerController.seekTo(pos, 0) - mediaPlayerController.prepare() - mediaPlayerController.play() - onCurrentChanged() - onSliderProgressChanged() + val clickHandler: ((Track, Int) -> Unit) = { _, listPos -> + val mediaIndex = mediaPlayerController.getUnshuffledIndexOf(listPos) + mediaPlayerController.play(mediaIndex) } viewAdapter.register( @@ -931,6 +943,7 @@ class PlayerFragment : if (actionState == ACTION_STATE_IDLE && dragging) { dragging = false // Move the item in the playlist separately + Timber.i("Moving item %s to %s", startPosition, endPosition) mediaPlayerController.moveItemInPlaylist(startPosition, endPosition) } } @@ -1010,7 +1023,8 @@ class PlayerFragment : private fun onPlaylistChanged() { val mediaPlayerController = mediaPlayerController - val list = mediaPlayerController.playlist + // Try to display playlist in play order + val list = mediaPlayerController.playlistInPlayOrder emptyTextView.setText(R.string.playlist_empty) viewAdapter.submitList(list.map(MediaItem::toTrack)) @@ -1020,7 +1034,7 @@ class PlayerFragment : updateRepeatButtonState(mediaPlayerController.repeatMode) } - private fun onCurrentChanged() { + private fun onTrackChanged() { currentSong = mediaPlayerController.currentMediaItem?.toTrack() scrollToCurrent() @@ -1064,7 +1078,7 @@ class PlayerFragment : it.loadImage(albumArtImageView, currentSong, true, 0) } - displaySongRating() + updateSongRating() } else { currentSong = null songTitleTextView.text = null @@ -1078,24 +1092,24 @@ class PlayerFragment : it.loadImage(albumArtImageView, null, true, 0) } } + + updateSongRating() + + nextButton.isEnabled = mediaPlayerController.canSeekToNext() + previousButton.isEnabled = mediaPlayerController.canSeekToPrevious() } - @Suppress("LongMethod") @Synchronized - private fun onSliderProgressChanged() { - + private fun updateSeekBar() { val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled val millisPlayed: Int = max(0, mediaPlayerController.playerPosition) val duration: Int = mediaPlayerController.playerDuration val playbackState: Int = mediaPlayerController.playbackState - val isPlaying = mediaPlayerController.isPlaying - if (cancellationToken.isCancellationRequested) return if (currentSong != null) { positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true) durationTextView.text = Util.formatTotalDuration(duration.toLong(), true) - progressBar.max = - if (duration == 0) 100 else duration // Work-around for apparent bug. + progressBar.max = if (duration == 0) 100 else duration // Work-around for apparent bug. progressBar.progress = millisPlayed progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled } else { @@ -1107,18 +1121,18 @@ class PlayerFragment : } val progress = mediaPlayerController.bufferedPercentage + updateBufferProgress(playbackState, progress) + } + private fun updateTitle(playbackState: Int) { when (playbackState) { Player.STATE_BUFFERING -> { - val downloadStatus = resources.getString( R.string.download_playerstate_loading ) - progressBar.secondaryProgress = progress setTitle(this@PlayerFragment, downloadStatus) } Player.STATE_READY -> { - progressBar.secondaryProgress = progress if (mediaPlayerController.isShufflePlayEnabled) { setTitle( this@PlayerFragment, @@ -1128,13 +1142,22 @@ class PlayerFragment : setTitle(this@PlayerFragment, R.string.common_appname) } } - Player.STATE_IDLE, - Player.STATE_ENDED, - -> { - } + Player.STATE_IDLE, Player.STATE_ENDED -> {} else -> setTitle(this@PlayerFragment, R.string.common_appname) } + } + private fun updateBufferProgress(playbackState: Int, progress: Int) { + when (playbackState) { + Player.STATE_BUFFERING, Player.STATE_READY -> { + progressBar.secondaryProgress = progress + } + else -> { } + } + } + + private fun updateButtonStates(playbackState: Int) { + val isPlaying = mediaPlayerController.isPlaying when (playbackState) { Player.STATE_READY -> { pauseButton.isVisible = isPlaying @@ -1152,10 +1175,6 @@ class PlayerFragment : playButton.isVisible = true } } - - // TODO: It would be a lot nicer if MediaPlayerController would send an event - // when this is necessary instead of updating every time - displaySongRating() } private fun seek(forward: Boolean) { @@ -1189,18 +1208,14 @@ class PlayerFragment : // Right to Left swipe if (e1X - e2X > swipeDistance && absX > swipeVelocity) { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - mediaPlayerController.next() - onCurrentChanged() - onSliderProgressChanged() + mediaPlayerController.seekToNext() return true } // Left to Right swipe if (e2X - e1X > swipeDistance && absX > swipeVelocity) { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - mediaPlayerController.previous() - onCurrentChanged() - onSliderProgressChanged() + mediaPlayerController.seekToPrevious() return true } @@ -1208,7 +1223,6 @@ class PlayerFragment : if (e2Y - e1Y > swipeDistance && absY > swipeVelocity) { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() mediaPlayerController.seekTo(mediaPlayerController.playerPosition + 30000) - onSliderProgressChanged() return true } @@ -1216,7 +1230,6 @@ class PlayerFragment : if (e1Y - e2Y > swipeDistance && absY > swipeVelocity) { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() mediaPlayerController.seekTo(mediaPlayerController.playerPosition - 8000) - onSliderProgressChanged() return true } return false @@ -1237,12 +1250,8 @@ class PlayerFragment : return false } - private fun displaySongRating() { - var rating = 0 - - if (currentSong?.userRating != null) { - rating = currentSong!!.userRating!! - } + private fun updateSongRating() { + val rating = currentSong?.userRating ?: 0 fiveStar1ImageView.setImageResource(if (rating > 0) fullStar else hollowStar) fiveStar2ImageView.setImageResource(if (rating > 1) fullStar else hollowStar) @@ -1253,8 +1262,15 @@ class PlayerFragment : private fun setSongRating(rating: Int) { if (currentSong == null) return - displaySongRating() - mediaPlayerController.setSongRating(rating) + currentSong?.userRating = rating + updateSongRating() + + RxBus.ratingSubmitter.onNext( + RatingUpdate( + currentSong!!.id, + StarRating(5, rating.toFloat()) + ) + ) } @SuppressLint("InflateParams") 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 323f8d05..9dfcc1ef 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -309,7 +309,6 @@ class SearchFragment : MultiListFragment(), KoinComponent { } mediaPlayerController.addToPlaylist( listOf(song), - cachePermanently = false, autoPlay = false, shuffle = false, insertionMode = MediaPlayerController.InsertionMode.APPEND @@ -367,40 +366,37 @@ class SearchFragment : MultiListFragment(), KoinComponent { when (menuItem.itemId) { R.id.song_menu_play_now -> { songs.add(item) - downloadHandler.download( - fragment = this, - append = false, - save = false, - autoPlay = true, - playNext = false, - shuffle = false, + downloadHandler.addTracksToMediaController( songs = songs, + append = false, + playNext = false, + autoPlay = true, + shuffle = false, + fragment = this, playlistName = null ) } R.id.song_menu_play_next -> { songs.add(item) - downloadHandler.download( - fragment = this, - append = true, - save = false, - autoPlay = false, - playNext = true, - shuffle = false, + downloadHandler.addTracksToMediaController( songs = songs, + append = true, + playNext = true, + autoPlay = false, + shuffle = false, + fragment = this, playlistName = null ) } R.id.song_menu_play_last -> { songs.add(item) - downloadHandler.download( - fragment = this, - append = true, - save = false, - autoPlay = false, - playNext = false, - shuffle = false, + downloadHandler.addTracksToMediaController( songs = songs, + append = true, + playNext = false, + autoPlay = false, + shuffle = false, + fragment = this, playlistName = null ) } 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 6664f17e..7d38ba24 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -40,11 +40,10 @@ import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.model.TrackCollectionModel -import org.moire.ultrasonic.service.DownloadService import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign -import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker +import org.moire.ultrasonic.subsonic.DownloadAction import org.moire.ultrasonic.subsonic.ShareHandler import org.moire.ultrasonic.subsonic.VideoPlayer import org.moire.ultrasonic.util.CancellationToken @@ -84,7 +83,6 @@ open class TrackCollectionFragment( private var shareButton: MenuItem? = null internal val mediaPlayerController: MediaPlayerController by inject() - private val networkAndStorageChecker: NetworkAndStorageChecker by inject() private val shareHandler: ShareHandler by inject() internal var cancellationToken: CancellationToken? = null @@ -211,11 +209,14 @@ open class TrackCollectionFragment( } playNextButton?.setOnClickListener { - downloadHandler.download( - this@TrackCollectionFragment, append = true, - save = false, autoPlay = false, playNext = true, shuffle = false, + downloadHandler.addTracksToMediaController( songs = getSelectedSongs(), - playlistName = navArgs.playlistName + append = true, + playNext = true, + autoPlay = false, + shuffle = false, + playlistName = navArgs.playlistName, + this@TrackCollectionFragment ) } @@ -304,9 +305,14 @@ open class TrackCollectionFragment( selectedSongs: List = getSelectedSongs() ) { if (selectedSongs.isNotEmpty()) { - downloadHandler.download( - this, append, false, !append, playNext = false, - shuffle = false, songs = selectedSongs, null + downloadHandler.addTracksToMediaController( + songs = selectedSongs, + append = append, + playNext = false, + autoPlay = !append, + shuffle = false, + playlistName = null, + fragment = this ) } else { playAll(false, append) @@ -337,31 +343,29 @@ open class TrackCollectionFragment( } val isArtist = navArgs.isArtist - val id = navArgs.id + + // Need a valid id to download stuff + val id = navArgs.id ?: return if (hasSubFolders) { - downloadHandler.downloadRecursively( + downloadHandler.fetchTracksAndAddToController( fragment = this, id = id, - save = false, append = append, autoPlay = !append, shuffle = shuffle, - background = false, playNext = false, - unpin = false, isArtist = isArtist ) } else { - downloadHandler.download( - fragment = this, - append = append, - save = false, - autoPlay = !append, - playNext = false, - shuffle = shuffle, + downloadHandler.addTracksToMediaController( songs = getAllSongs(), - playlistName = navArgs.playlistName + append = append, + playNext = false, + autoPlay = !append, + shuffle = shuffle, + playlistName = navArgs.playlistName, + fragment = this ) } } @@ -416,62 +420,35 @@ open class TrackCollectionFragment( } } - private fun downloadBackground(save: Boolean) { - var songs = getSelectedSongs() + private fun downloadBackground(save: Boolean, tracks: List = getSelectedSongs()) { + var songs = tracks if (songs.isEmpty()) { songs = getAllSongs() } - downloadBackground(save, songs) - } - - private fun downloadBackground( - save: Boolean, - songs: List - ) { - val onValid = Runnable { - networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - DownloadService.download(songs.filterNotNull(), save) - - if (save) { - Util.toast( - context, - resources.getQuantityString( - R.plurals.select_album_n_songs_pinned, songs.size, songs.size - ) - ) - } else { - Util.toast( - context, - resources.getQuantityString( - R.plurals.select_album_n_songs_downloaded, songs.size, songs.size - ) - ) - } - } - onValid.run() + val action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD + downloadHandler.justDownload( + action = action, + fragment = this, + tracks = songs + ) } internal fun delete(songs: List = getSelectedSongs()) { - Util.toast( - context, - resources.getQuantityString( - R.plurals.select_album_n_songs_deleted, songs.size, songs.size - ) + downloadHandler.justDownload( + action = DownloadAction.DELETE, + fragment = this, + tracks = songs ) - - DownloadService.delete(songs) } internal fun unpin(songs: List = getSelectedSongs()) { - Util.toast( - context, - resources.getQuantityString( - R.plurals.select_album_n_songs_unpinned, songs.size, songs.size - ) + downloadHandler.justDownload( + action = DownloadAction.UNPIN, + fragment = this, + tracks = songs ) - DownloadService.unpin(songs) } override val defaultObserver: (List) -> Unit = { @@ -597,14 +574,14 @@ open class TrackCollectionFragment( } else if (getVideos) { setTitle(R.string.main_videos) listModel.getVideos(refresh2) - } else if (getRandomTracks) { + } else if (id == null || getRandomTracks) { + // There seems to be a bug in ViewPager when resuming the Actitivy that subfragments + // arguments are empty. If we have no id, just show some random tracks setTitle(R.string.main_songs_random) listModel.getRandom(size, append) } else { setTitle(name) - requireNotNull(id) { - "ID must be set. NavArgs: ${navArgs.toBundle()}" - } + if (ActiveServerProvider.isID3Enabled()) { if (isAlbum) { listModel.getAlbum(refresh2, id, name) @@ -637,15 +614,14 @@ open class TrackCollectionFragment( playNow(false, songs) } R.id.song_menu_play_next -> { - downloadHandler.download( - fragment = this@TrackCollectionFragment, - append = true, - save = false, - autoPlay = false, - playNext = true, - shuffle = false, + downloadHandler.addTracksToMediaController( songs = songs, - playlistName = navArgs.playlistName + append = true, + playNext = true, + autoPlay = false, + shuffle = false, + playlistName = navArgs.playlistName, + fragment = this@TrackCollectionFragment ) } R.id.song_menu_play_last -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt index ff2f8a73..3a077b45 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt @@ -38,6 +38,7 @@ import org.moire.ultrasonic.domain.Playlist import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.OfflineException +import org.moire.ultrasonic.subsonic.DownloadAction import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.util.BackgroundTask import org.moire.ultrasonic.util.CacheCleaner @@ -147,45 +148,33 @@ class PlaylistsFragment : Fragment() { val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist when (menuItem.itemId) { R.id.playlist_menu_pin -> { - downloadHandler.value.downloadPlaylist( - this, + downloadHandler.value.justDownload( + DownloadAction.PIN, + fragment = this, id = playlist.id, name = playlist.name, - save = true, - append = true, - autoplay = false, - shuffle = false, - background = true, - playNext = false, - unpin = false + isShare = false, + isDirectory = false ) } R.id.playlist_menu_unpin -> { - downloadHandler.value.downloadPlaylist( - this, + downloadHandler.value.justDownload( + DownloadAction.UNPIN, + fragment = this, id = playlist.id, name = playlist.name, - save = false, - append = false, - autoplay = false, - shuffle = false, - background = true, - playNext = false, - unpin = true + isShare = false, + isDirectory = false ) } R.id.playlist_menu_download -> { - downloadHandler.value.downloadPlaylist( - this, + downloadHandler.value.justDownload( + DownloadAction.DOWNLOAD, + fragment = this, id = playlist.id, name = playlist.name, - save = false, - append = false, - autoplay = false, - shuffle = false, - background = true, - playNext = false, - unpin = false + isShare = false, + isDirectory = false ) } R.id.playlist_menu_play_now -> { 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 eae31252..49c33749 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 @@ -28,7 +28,8 @@ import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import java.util.Locale -import org.koin.java.KoinJavaComponent +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.R import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException @@ -36,6 +37,7 @@ import org.moire.ultrasonic.domain.Share import org.moire.ultrasonic.fragment.FragmentTitle import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.OfflineException +import org.moire.ultrasonic.subsonic.DownloadAction import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.util.BackgroundTask import org.moire.ultrasonic.util.CancellationToken @@ -50,14 +52,12 @@ import org.moire.ultrasonic.view.ShareAdapter * * TODO: This file has been converted from Java, but not modernized yet. */ -class SharesFragment : Fragment() { +class SharesFragment : Fragment(), KoinComponent { private var refreshSharesListView: SwipeRefreshLayout? = null private var sharesListView: ListView? = null private var emptyTextView: View? = null private var shareAdapter: ShareAdapter? = null - private val downloadHandler = KoinJavaComponent.inject( - DownloadHandler::class.java - ) + private val downloadHandler = inject() private var cancellationToken: CancellationToken? = null override fun onCreate(savedInstanceState: Bundle?) { Util.applyTheme(this.context) @@ -72,7 +72,6 @@ class SharesFragment : Fragment() { return inflater.inflate(R.layout.select_share, container, false) } - @Suppress("NAME_SHADOWING") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { cancellationToken = CancellationToken() refreshSharesListView = view.findViewById(R.id.select_share_refresh) @@ -132,73 +131,55 @@ class SharesFragment : Fragment() { val share = sharesListView!!.getItemAtPosition(info.position) as Share when (menuItem.itemId) { R.id.share_menu_pin -> { - downloadHandler.value.downloadShare( - this, - share.id, - share.name, - save = true, - append = true, - autoplay = false, - shuffle = false, - background = true, - playNext = false, - unpin = false + downloadHandler.value.justDownload( + DownloadAction.PIN, + fragment = this, + id = share.id, + name = share.name, + isShare = true, + isDirectory = false ) } R.id.share_menu_unpin -> { - downloadHandler.value.downloadShare( - this, - share.id, - share.name, - save = false, - append = false, - autoplay = false, - shuffle = false, - background = true, - playNext = false, - unpin = true + downloadHandler.value.justDownload( + DownloadAction.UNPIN, + fragment = this, + id = share.id, + name = share.name, + isShare = true, + isDirectory = false ) } R.id.share_menu_download -> { - downloadHandler.value.downloadShare( - this, - share.id, - share.name, - save = false, - append = false, - autoplay = false, - shuffle = false, - background = true, - playNext = false, - unpin = false + downloadHandler.value.justDownload( + DownloadAction.DOWNLOAD, + fragment = this, + id = share.id, + name = share.name, + isShare = true, + isDirectory = false ) } R.id.share_menu_play_now -> { - downloadHandler.value.downloadShare( + downloadHandler.value.fetchTracksAndAddToController( this, share.id, share.name, - save = false, append = false, - autoplay = true, + autoPlay = true, shuffle = false, - background = false, playNext = false, - unpin = false ) } R.id.share_menu_play_shuffled -> { - downloadHandler.value.downloadShare( + downloadHandler.value.fetchTracksAndAddToController( this, share.id, share.name, - save = false, append = false, - autoplay = true, + autoPlay = true, shuffle = true, - background = false, playNext = false, - unpin = false ) } R.id.share_menu_delete -> { 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 fcf0b9f8..8921bb31 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt @@ -26,8 +26,6 @@ import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult -import androidx.media3.session.SessionResult.RESULT_ERROR_BAD_VALUE -import androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN import androidx.media3.session.SessionResult.RESULT_SUCCESS import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.FutureCallback @@ -44,12 +42,14 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.api.subsonic.models.AlbumListType import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.data.RatingUpdate 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.MediaPlayerController import org.moire.ultrasonic.service.MusicServiceFactory +import org.moire.ultrasonic.service.RatingManager import org.moire.ultrasonic.util.MainThreadExecutor import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util @@ -306,21 +306,18 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr rating: Rating ): ListenableFuture { return serviceScope.future { - if (rating is HeartRating) { - try { - if (rating.isHeart) { - musicService.star(mediaId, null, null) - } else { - musicService.unstar(mediaId, null, null) - } - } catch (all: Exception) { - Timber.e(all) - // TODO: Better handle exception - return@future SessionResult(RESULT_ERROR_UNKNOWN) - } - return@future SessionResult(RESULT_SUCCESS) - } - return@future SessionResult(RESULT_ERROR_BAD_VALUE) + Timber.i(controller.packageName) + // This function even though its declared in AutoMediaBrowserCallback.kt is + // actually called every time we set the rating on an MediaItem. + // To avoid an event loop it does not emit a RatingUpdate event, + // but calls the Manager directly + RatingManager.instance.submitRating( + RatingUpdate( + id = mediaId, + rating = rating + ) + ) + return@future SessionResult(RESULT_SUCCESS) } } 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 019263db..7603f94f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -26,9 +26,12 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import androidx.media3.exoplayer.source.ShuffleOrder.UnshuffledShuffleOrder import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import io.reactivex.rxjava3.disposables.CompositeDisposable +import java.util.Random import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -175,6 +178,27 @@ class PlaybackService : player.setWakeMode(getWakeModeFlag()) } + // Set a listener to reset the ShuffleOrder + rxBusSubscription += RxBus.shufflePlayObservable.subscribe { shuffle -> + val len = player.currentTimeline.windowCount + + Timber.i("Resetting shuffle order, isShuffled: %s", shuffle) + + // If disabling Shuffle return early + if (!shuffle) { + return@subscribe player.setShuffleOrder(UnshuffledShuffleOrder(len)) + } + + // Get the position of the current track in the unshuffled order + val cur = player.currentMediaItemIndex + val seed = System.currentTimeMillis() + val random = Random(seed) + + val list = createShuffleListFromCurrentIndex(cur, len, random) + Timber.i("New Shuffle order: %s", list.joinToString { it.toString() }) + player.setShuffleOrder(DefaultShuffleOrder(list, seed)) + } + // Listen to the shutdown command rxBusSubscription += RxBus.shutdownCommandObservable.subscribe { Timber.i("Received destroy command via Rx") @@ -185,6 +209,24 @@ class PlaybackService : isStarted = true } + fun createShuffleListFromCurrentIndex( + currentIndex: Int, + length: Int, + random: Random + ): IntArray { + val list = IntArray(length) { it } + + // Shuffle the remaining items using a swapping algorithm + for (i in currentIndex + 1 until length) { + val swapIndex = (currentIndex + 1) + random.nextInt(i - currentIndex) + val swapItem = list[i] + list[i] = list[swapIndex] + list[swapIndex] = swapItem + } + + return list + } + private val listener: Player.Listener = object : Player.Listener { override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { cacheNextSongs() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index b7f81c09..2be0f26f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -10,20 +10,21 @@ import android.content.ComponentName import android.content.Context import android.os.Handler import android.os.Looper -import android.widget.Toast +import androidx.annotation.IntRange import androidx.media3.common.C import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT +import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Rating +import androidx.media3.common.StarRating import androidx.media3.common.Timeline import androidx.media3.session.MediaController -import androidx.media3.session.SessionResult import androidx.media3.session.SessionToken -import com.google.common.util.concurrent.FutureCallback -import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors import io.reactivex.rxjava3.disposables.CompositeDisposable @@ -35,13 +36,12 @@ import org.koin.core.component.inject import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID +import org.moire.ultrasonic.data.RatingUpdate import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.playback.PlaybackService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.MainThreadExecutor import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util -import org.moire.ultrasonic.util.setPin import org.moire.ultrasonic.util.toMediaItem import org.moire.ultrasonic.util.toTrack import timber.log.Timber @@ -60,6 +60,7 @@ class MediaPlayerController( private val externalStorageMonitor: ExternalStorageMonitor, val context: Context ) : KoinComponent { + private val activeServerProvider: ActiveServerProvider by inject() private var created = false @@ -96,6 +97,14 @@ class MediaPlayerController( * We run the event through RxBus in order to throttle them */ override fun onTimelineChanged(timeline: Timeline, reason: Int) { + val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled) + Timber.w("On timeline changed. First shuffle play at index: %s", start) + deferredPlay?.let { + Timber.w("Executing deferred shuffle play") + it() + deferredPlay = null + } + RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack)) } @@ -150,19 +159,21 @@ class MediaPlayerController( override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { val timeline: Timeline = controller!!.currentTimeline - var windowIndex = timeline.getFirstWindowIndex( /* shuffleModeEnabled= */true) + var windowIndex = timeline.getFirstWindowIndex(true) var count = 0 Timber.d("Shuffle: windowIndex: $windowIndex, at: $count") while (windowIndex != C.INDEX_UNSET) { count++ windowIndex = timeline.getNextWindowIndex( - windowIndex, REPEAT_MODE_OFF, /* shuffleModeEnabled= */true + windowIndex, REPEAT_MODE_OFF, true ) Timber.d("Shuffle: windowIndex: $windowIndex, at: $count") } } } + private var deferredPlay: (() -> Unit)? = null + private var cachedMediaItem: MediaItem? = null fun onCreate(onCreated: () -> Unit) { @@ -216,11 +227,21 @@ class MediaPlayerController( clear(false) onDestroy() } + rxBusSubscription += RxBus.stopServiceCommandObservable.subscribe { clear(false) onDestroy() } + rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe { + // Ensure correct thread + mainScope.launch { + // This deals only with the current track! + if (it.id != currentMediaItem?.toTrack()?.id) return@launch + setRating(it.rating) + } + } + created = true Timber.i("MediaPlayerController started") } @@ -259,7 +280,7 @@ class MediaPlayerController( private fun publishPlaybackState() { val newState = RxBus.StateWithTrack( track = currentMediaItem?.toTrack(), - index = currentMediaItemIndex, + index = if (isShufflePlayEnabled) getCurrentShuffleIndex() else currentMediaItemIndex, isPlaying = isPlaying, state = playbackState ) @@ -292,7 +313,6 @@ class MediaPlayerController( addToPlaylist( state.songs, - cachePermanently = false, autoPlay = false, shuffle = false, insertionMode = insertionMode @@ -316,6 +336,8 @@ class MediaPlayerController( @Synchronized fun play(index: Int) { controller?.seekTo(index, 0L) + // FIXME CHECK ITS NOT MAKING PROBLEMS + controller?.prepare() controller?.play() } @@ -384,7 +406,6 @@ class MediaPlayerController( @Synchronized fun addToPlaylist( songs: List, - cachePermanently: Boolean, autoPlay: Boolean, shuffle: Boolean, insertionMode: InsertionMode @@ -399,11 +420,11 @@ class MediaPlayerController( val mediaItems: List = songs.map { val result = it.toMediaItem() - if (cachePermanently) result.setPin(true) result } if (shuffle) isShufflePlayEnabled = true + Timber.w("Adding ${mediaItems.size} media items") controller?.addMediaItems(insertAt, mediaItems) prepare() @@ -411,10 +432,19 @@ class MediaPlayerController( // Playback doesn't start correctly when the player is in STATE_ENDED. // So we need to call seek before (this is what play(0,0)) does. // We can't just use play(0,0) then all random playlists will start with the first track. - // This means that we need to generate the random first track ourselves. + // 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) { - val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled) ?: 0 - play(start) + if (isShufflePlayEnabled) { + deferredPlay = { + val start = controller?.currentTimeline + ?.getFirstWindowIndex(isShufflePlayEnabled) ?: 0 + Timber.i("Deferred shuffle play starting now at index: %s", start) + play(start) + } + } else { + play(0) + } } } @@ -422,6 +452,8 @@ class MediaPlayerController( var isShufflePlayEnabled: Boolean get() = controller?.shuffleModeEnabled == true set(enabled) { + Timber.i("Shuffle is now enabled: %s", enabled) + RxBus.shufflePlayPublisher.onNext(enabled) controller?.shuffleModeEnabled = enabled } @@ -431,11 +463,17 @@ class MediaPlayerController( return isShufflePlayEnabled } + /** + * Returns an estimate of the percentage in the current content up to which data is + * buffered, or 0 if no estimate is available. + */ + @get:IntRange(from = 0, to = 100) val bufferedPercentage: Int get() = controller?.bufferedPercentage ?: 0 @Synchronized fun moveItemInPlaylist(oldPos: Int, newPos: Int) { + // TODO: This currently does not care about shuffle position. controller?.moveMediaItem(oldPos, newPos) } @@ -494,15 +532,25 @@ class MediaPlayerController( } @Synchronized - fun previous() { + fun seekToPrevious() { controller?.seekToPrevious() } @Synchronized - operator fun next() { + fun canSeekToPrevious(): Boolean { + return controller?.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS) == true + } + + @Synchronized + fun seekToNext() { controller?.seekToNext() } + @Synchronized + fun canSeekToNext(): Boolean { + return controller?.isCommandAvailable(COMMAND_SEEK_TO_NEXT) == true + } + @Synchronized fun reset() { controller?.clearMediaItems() @@ -656,52 +704,49 @@ class MediaPlayerController( controller?.volume = volume } - fun toggleSongStarred(): ListenableFuture? { - if (currentMediaItem == null) return null - val song = currentMediaItem!!.toTrack() - - return (controller as? MediaController)?.setRating( - HeartRating(!song.starred) - )?.let { - Futures.addCallback( - it, - object : FutureCallback { - override fun onSuccess(result: SessionResult?) { - // Trigger an update - // TODO Update Metadata of MediaItem... - // localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) - song.starred = !song.starred - } - - override fun onFailure(t: Throwable) { - Toast.makeText( - context, - "There was an error updating the rating", - Toast.LENGTH_SHORT - ).show() - } - }, - MainThreadExecutor() - ) - it + /* + * Sets the rating of the current track + */ + fun setRating(rating: Rating) { + if (controller is MediaController) { + (controller as MediaController).setRating(rating) } } - @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions - fun setSongRating(rating: Int) { - if (!Settings.useFiveStarRating) return + /* + * This legacy function simply emits a rating update, + * which will then be processed by both the RatingManager as well as the controller + */ + fun legacyToggleStar() { if (currentMediaItem == null) return - val song = currentMediaItem!!.toTrack() - song.userRating = rating - Thread { - try { - getMusicService().setRating(song.id, rating) - } catch (e: Exception) { - Timber.e(e) - } - }.start() - // TODO this would be better handled with a Rx command - // updateNotification() + val track = currentMediaItem!!.toTrack() + track.starred = !track.starred + val rating = HeartRating(track.starred) + + RxBus.ratingSubmitter.onNext( + RatingUpdate( + track.id, + rating + ) + ) + } + + /* + * This legacy function simply emits a rating update, + * which will then be processed by both the RatingManager as well as the controller + */ + fun legacySetRating(num: Int) { + if (currentMediaItem == null) return + val track = currentMediaItem!!.toTrack() + track.userRating = num + val rating = StarRating(5, num.toFloat()) + + RxBus.ratingSubmitter.onNext( + RatingUpdate( + track.id, + rating + ) + ) } val currentMediaItem: MediaItem? @@ -710,9 +755,64 @@ class MediaPlayerController( val currentMediaItemIndex: Int get() = controller?.currentMediaItemIndex ?: -1 + fun getCurrentShuffleIndex(): Int { + val currentMediaItemIndex = controller?.currentMediaItemIndex ?: return -1 + return getShuffledIndexOf(currentMediaItemIndex) + } + + /** + * Loops over the timeline windows to find the entry which matches the given closure. + * + * @param searchClosure Determines the condition which the searched for window needs to match. + * @return the index of the window that satisfies the search condition, + * or [C.INDEX_UNSET] if not found. + */ + private fun getWindowIndexWhere(searchClosure: (Int, Int) -> Boolean): Int { + val timeline = controller?.currentTimeline!! + var windowIndex = timeline.getFirstWindowIndex(true) + var count = 0 + while (windowIndex != C.INDEX_UNSET) { + if (searchClosure(count, windowIndex)) return count + count++ + windowIndex = timeline.getNextWindowIndex( + windowIndex, REPEAT_MODE_OFF, true + ) + } + + return C.INDEX_UNSET + } + + /** + * Returns the index of the shuffled position of the current playback item given its original + * position in the unshuffled timeline. + * + * @param searchPosition The index of the item in the unshuffled timeline to search for + * in the shuffled timeline. + * @return The index of the item in the shuffled timeline, or [C.INDEX_UNSET] if not found. + */ + fun getShuffledIndexOf(searchPosition: Int): Int { + return getWindowIndexWhere { _, windowIndex -> windowIndex == searchPosition } + } + + /** + * Returns the index of the unshuffled position of the current playback item given its shuffled + * position in the shuffled timeline. + * + * @param shufflePosition the index of the item in the shuffled timeline to search for in the + * unshuffled timeline. + * @return the index of the item in the unshuffled timeline, or [C.INDEX_UNSET] if not found. + */ + fun getUnshuffledIndexOf(shufflePosition: Int): Int { + return getWindowIndexWhere { count, _ -> count == shufflePosition } + } + val mediaItemCount: Int get() = controller?.mediaItemCount ?: 0 + fun getMediaItemAt(index: Int): MediaItem? { + return controller?.getMediaItemAt(index) + } + val playlistSize: Int get() = controller?.currentTimeline?.windowCount ?: 0 @@ -721,10 +821,6 @@ class MediaPlayerController( return Util.getPlayListFromTimeline(controller?.currentTimeline, false) } - fun getMediaItemAt(index: Int): MediaItem? { - return controller?.getMediaItemAt(index) - } - val playlistInPlayOrder: List get() { return Util.getPlayListFromTimeline( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt index d412d0e6..3c5d4df3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -30,6 +30,7 @@ import timber.log.Timber * This class is responsible for handling received events for the Media Player implementation */ class MediaPlayerLifecycleSupport : KoinComponent { + private lateinit var ratingManager: RatingManager private val playbackStateSerializer by inject() private val mediaPlayerController by inject() private val imageLoaderProvider: ImageLoaderProvider by inject() @@ -71,6 +72,7 @@ class MediaPlayerLifecycleSupport : KoinComponent { CacheCleaner().clean() created = true + ratingManager = RatingManager.instance Timber.i("LifecycleSupport created") } @@ -182,17 +184,17 @@ class MediaPlayerLifecycleSupport : KoinComponent { when (keyCode) { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerController.togglePlayPause() - KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous() - KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next() + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.seekToPrevious() + KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.seekToNext() KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop() KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play() KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause() - KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1) - KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2) - KeyEvent.KEYCODE_3 -> mediaPlayerController.setSongRating(3) - KeyEvent.KEYCODE_4 -> mediaPlayerController.setSongRating(4) - KeyEvent.KEYCODE_5 -> mediaPlayerController.setSongRating(5) - KeyEvent.KEYCODE_STAR -> mediaPlayerController.toggleSongStarred() + KeyEvent.KEYCODE_1 -> mediaPlayerController.legacySetRating(1) + KeyEvent.KEYCODE_2 -> mediaPlayerController.legacySetRating(2) + KeyEvent.KEYCODE_3 -> mediaPlayerController.legacySetRating(3) + KeyEvent.KEYCODE_4 -> mediaPlayerController.legacySetRating(4) + KeyEvent.KEYCODE_5 -> mediaPlayerController.legacySetRating(5) + KeyEvent.KEYCODE_STAR -> mediaPlayerController.legacyToggleStar() else -> { } } @@ -226,8 +228,8 @@ class MediaPlayerLifecycleSupport : KoinComponent { // no need to call anything if (isRunning) mediaPlayerController.resumeOrPlay() - Constants.CMD_NEXT -> mediaPlayerController.next() - Constants.CMD_PREVIOUS -> mediaPlayerController.previous() + Constants.CMD_NEXT -> mediaPlayerController.seekToNext() + Constants.CMD_PREVIOUS -> mediaPlayerController.seekToPrevious() Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause() Constants.CMD_STOP -> mediaPlayerController.stop() Constants.CMD_PAUSE -> mediaPlayerController.pause() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt index eb3c8bb9..2d37a648 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt @@ -39,10 +39,10 @@ interface MusicService { fun getGenres(refresh: Boolean): List @Throws(Exception::class) - fun star(id: String?, albumId: String?, artistId: String?) + fun star(id: String?, albumId: String? = null, artistId: String? = null) @Throws(Exception::class) - fun unstar(id: String?, albumId: String?, artistId: String?) + fun unstar(id: String?, albumId: String? = null, artistId: String? = null) @Throws(Exception::class) fun setRating(id: String, rating: Int) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RatingManager.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RatingManager.kt new file mode 100644 index 00000000..dd2bd38b --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RatingManager.kt @@ -0,0 +1,87 @@ +/* + * RatingManager.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import androidx.media3.common.HeartRating +import androidx.media3.common.StarRating +import io.reactivex.rxjava3.disposables.CompositeDisposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.moire.ultrasonic.data.RatingUpdate +import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import timber.log.Timber + +/* +* This class subscribes to RatingEvents and submits them to the server. +* In the future it could be extended to store the ratings when offline +* and submit them when back online. +* Only the manager should publish RatingSubmitted events + */ +class RatingManager : CoroutineScope by CoroutineScope(Dispatchers.Default) { + private val rxBusSubscription: CompositeDisposable = CompositeDisposable() + + var lastUpdate: RatingUpdate? = null + + init { + rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe { + submitRating(it) + } + } + + internal fun submitRating(update: RatingUpdate) { + // Don't submit the same rating twice + if (update.id == lastUpdate?.id && update.rating == lastUpdate?.rating) return + + val service = getMusicService() + val id = update.id + + Timber.i("Submitting rating to server: ${update.rating} for $id") + + if (update.rating is HeartRating) { + launch { + var success = false + withContext(Dispatchers.IO) { + try { + if (update.rating.isHeart) service.star(id) + else service.unstar(id) + success = true + } catch (all: Exception) { + Timber.e(all) + } + } + RxBus.ratingPublished.onNext( + update.copy(success = success) + ) + } + } else if (update.rating is StarRating) { + launch { + var success = false + withContext(Dispatchers.IO) { + try { + getMusicService().setRating(id, update.rating.starRating.toInt()) + success = true + } catch (all: Exception) { + Timber.e(all) + } + } + RxBus.ratingPublished.onNext( + update.copy(success = success) + ) + } + } + lastUpdate = update + } + + companion object { + val instance: RatingManager by lazy { + RatingManager() + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt index 48a1061f..9c87c519 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt @@ -7,6 +7,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.subjects.PublishSubject import java.util.concurrent.TimeUnit +import org.moire.ultrasonic.data.RatingUpdate import org.moire.ultrasonic.domain.Track class RxBus { @@ -20,9 +21,13 @@ class RxBus { private fun mainThread() = AndroidSchedulers.from(Looper.getMainLooper()) + val shufflePlayPublisher: PublishSubject = + PublishSubject.create() + val shufflePlayObservable: Observable = + shufflePlayPublisher + var activeServerChangingPublisher: PublishSubject = PublishSubject.create() - // Subscribers should be called synchronously, not on another thread var activeServerChangingObservable: Observable = activeServerChangingPublisher @@ -71,6 +76,18 @@ class RxBus { val trackDownloadStateObservable: Observable = trackDownloadStatePublisher.observeOn(mainThread()) + // Sends a RatingUpdate which was just triggered by the user + val ratingSubmitter: PublishSubject = + PublishSubject.create() + val ratingSubmitterObservable: Observable = + ratingSubmitter + + // Sends a RatingUpdate which was successfully submitted to the server or database + val ratingPublished: PublishSubject = + PublishSubject.create() + val ratingPublishedObservable: Observable = + ratingPublished + // Commands val dismissNowPlayingCommandPublisher: PublishSubject = PublishSubject.create() 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 ce14c811..c327207b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt @@ -7,17 +7,11 @@ package org.moire.ultrasonic.subsonic -import android.os.Handler -import android.os.Looper import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController -import java.util.Collections import java.util.LinkedList -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.moire.ultrasonic.R import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline @@ -26,12 +20,8 @@ import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.service.DownloadService import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.CommunicationError -import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator -import org.moire.ultrasonic.util.InfoDialog import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.Util -import timber.log.Timber +import org.moire.ultrasonic.util.executeTaskWithToast /** * Retrieves a list of songs and adds them to the now playing list @@ -39,279 +29,145 @@ import timber.log.Timber @Suppress("LongParameterList") class DownloadHandler( val mediaPlayerController: MediaPlayerController, - val networkAndStorageChecker: NetworkAndStorageChecker + private val networkAndStorageChecker: NetworkAndStorageChecker ) : CoroutineScope by CoroutineScope(Dispatchers.IO) { private val maxSongs = 500 - /** - * Exception Handler for Coroutines - */ - val exceptionHandler = CoroutineExceptionHandler { _, exception -> - Handler(Looper.getMainLooper()).post { - Timber.w(exception) - } - } - - // TODO: Use coroutine here (with proper exception handler) - fun download( + fun justDownload( + action: DownloadAction, fragment: Fragment, - append: Boolean, - save: Boolean, - autoPlay: Boolean, - playNext: Boolean, - shuffle: Boolean, - songs: List, - playlistName: String?, + id: String? = null, + name: String? = "", + isShare: Boolean = false, + isDirectory: Boolean = true, + isArtist: Boolean = false, + tracks: List? = null ) { - val onValid = Runnable { - // TODO: The logic here is different than in the controller... - val insertionMode = when { - playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT - append -> MediaPlayerController.InsertionMode.APPEND - else -> MediaPlayerController.InsertionMode.CLEAR - } + var successString: String? = null - networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - mediaPlayerController.addToPlaylist( - songs, - save, - autoPlay, - shuffle, - insertionMode - ) - - if (playlistName != null) { - mediaPlayerController.suggestedPlaylistName = playlistName - } - if (autoPlay) { - if (Settings.shouldTransitionOnPlayback) { - fragment.findNavController().popBackStack(R.id.playerFragment, true) - fragment.findNavController().navigate(R.id.playerFragment) - } - } else if (save) { - Util.toast( - fragment.context, - fragment.resources.getQuantityString( - R.plurals.select_album_n_songs_pinned, - songs.size, - songs.size - ) - ) - } else if (playNext) { - Util.toast( - fragment.context, - fragment.resources.getQuantityString( - R.plurals.select_album_n_songs_play_next, - songs.size, - songs.size - ) - ) - } else if (append) { - Util.toast( - fragment.context, - fragment.resources.getQuantityString( - R.plurals.select_album_n_songs_added, - songs.size, - songs.size - ) - ) - } - } - onValid.run() - } - - fun downloadPlaylist( - fragment: Fragment, - id: String, - name: String?, - save: Boolean, - append: Boolean, - autoplay: Boolean, - shuffle: Boolean, - background: Boolean, - playNext: Boolean, - unpin: Boolean - ) { - downloadRecursively( - fragment, - id, - name, - isShare = false, - isDirectory = false, - save = save, - append = append, - autoPlay = autoplay, - shuffle = shuffle, - background = background, - playNext = playNext, - unpin = unpin, - isArtist = false - ) - } - - fun downloadShare( - fragment: Fragment, - id: String, - name: String?, - save: Boolean, - append: Boolean, - autoplay: Boolean, - shuffle: Boolean, - background: Boolean, - playNext: Boolean, - unpin: Boolean - ) { - downloadRecursively( - fragment, - id, - name, - isShare = true, - isDirectory = false, - save = save, - append = append, - autoPlay = autoplay, - shuffle = shuffle, - background = background, - playNext = playNext, - unpin = unpin, - isArtist = false - ) - } - - fun downloadRecursively( - fragment: Fragment, - id: String?, - save: Boolean, - append: Boolean, - autoPlay: Boolean, - shuffle: Boolean, - background: Boolean, - playNext: Boolean, - unpin: Boolean, - isArtist: Boolean - ) { - if (id.isNullOrEmpty()) return - downloadRecursively( - fragment, - id, - "", - isShare = false, - isDirectory = true, - save = save, - append = append, - autoPlay = autoPlay, - shuffle = shuffle, - background = background, - playNext = playNext, - unpin = unpin, - isArtist = isArtist - ) - } - - private fun downloadRecursively( - fragment: Fragment, - id: String, - name: String?, - isShare: Boolean, - isDirectory: Boolean, - save: Boolean, - append: Boolean, - autoPlay: Boolean, - shuffle: Boolean, - background: Boolean, - playNext: Boolean, - unpin: Boolean, - isArtist: Boolean - ) { // Launch the Job - val job = launch(exceptionHandler) { + executeTaskWithToast(fragment, { + val tracksToDownload: List = tracks + ?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare) + + withContext(Dispatchers.Main) { + // If we are just downloading tracks we don't need to add them to the controller + when (action) { + DownloadAction.DOWNLOAD -> DownloadService.download(tracksToDownload, false) + DownloadAction.PIN -> DownloadService.download(tracksToDownload, true) + DownloadAction.UNPIN -> DownloadService.unpin(tracksToDownload) + DownloadAction.DELETE -> DownloadService.delete(tracksToDownload) + } + successString = when (action) { + DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString( + R.plurals.select_album_n_songs_downloaded, + tracksToDownload.size, + tracksToDownload.size + ) + DownloadAction.UNPIN -> { + fragment.resources.getQuantityString( + R.plurals.select_album_n_songs_unpinned, + tracksToDownload.size, + tracksToDownload.size + ) + } + DownloadAction.PIN -> { + fragment.resources.getQuantityString( + R.plurals.select_album_n_songs_pinned, + tracksToDownload.size, + tracksToDownload.size + ) + } + DownloadAction.DELETE -> { + fragment.resources.getQuantityString( + R.plurals.select_album_n_songs_deleted, + tracksToDownload.size, + tracksToDownload.size + ) + } + } + } + }) { successString } + } + + fun fetchTracksAndAddToController( + fragment: Fragment, + id: String, + name: String? = "", + isShare: Boolean = false, + isDirectory: Boolean = true, + append: Boolean, + autoPlay: Boolean, + shuffle: Boolean, + playNext: Boolean, + isArtist: Boolean = false + ) { + var successString: String? = null + // Launch the Job + executeTaskWithToast(fragment, { val songs: MutableList = getTracksFromServer(isArtist, id, isDirectory, name, isShare) withContext(Dispatchers.Main) { addTracksToMediaController( - songs, - background, - unpin, - append, - playNext, - save, - autoPlay, - shuffle, - fragment + songs = songs, + append = append, + playNext = playNext, + 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 + ) + } } - } - - // Create the dialog - val builder = InfoDialog.Builder(fragment.requireContext()) - builder.setTitle(R.string.background_task_wait) - builder.setMessage(R.string.background_task_loading) - builder.setOnCancelListener { job.cancel() } - builder.setPositiveButton(R.string.common_cancel) { _, i -> job.cancel() } - val dialog = builder.create() - dialog.show() - - job.invokeOnCompletion { - dialog.dismiss() - if (it != null && it !is CancellationException) { - Util.toast( - fragment.requireContext(), - CommunicationError.getErrorMessage(it, fragment.requireContext()) - ) - } - } + }) { successString } } - private fun addTracksToMediaController( - songs: MutableList, - background: Boolean, - unpin: Boolean, + fun addTracksToMediaController( + songs: List, append: Boolean, playNext: Boolean, - save: Boolean, autoPlay: Boolean, shuffle: Boolean, + playlistName: String? = null, fragment: Fragment ) { if (songs.isEmpty()) return - if (Settings.shouldSortByDisc) { - Collections.sort(songs, EntryByDiscAndTrackComparator()) - } + networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - if (!background) { - if (unpin) { - DownloadService.unpin(songs) - } else { - val insertionMode = when { - append -> MediaPlayerController.InsertionMode.APPEND - playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT - else -> MediaPlayerController.InsertionMode.CLEAR - } - mediaPlayerController.addToPlaylist( - songs, - save, - autoPlay, - shuffle, - insertionMode - ) - if ( - !append && - Settings.shouldTransitionOnPlayback - ) { - fragment.findNavController().popBackStack( - R.id.playerFragment, - true - ) - fragment.findNavController().navigate(R.id.playerFragment) - } - } - } else { - if (unpin) { - DownloadService.unpin(songs) - } else { - DownloadService.download(songs, save) - } + + val insertionMode = when { + append -> MediaPlayerController.InsertionMode.APPEND + playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT + else -> MediaPlayerController.InsertionMode.CLEAR + } + + if (playlistName != null) { + mediaPlayerController.suggestedPlaylistName = playlistName + } + + mediaPlayerController.addToPlaylist( + songs, + autoPlay, + shuffle, + insertionMode + ) + if (Settings.shouldTransitionOnPlayback && (!append || autoPlay)) { + fragment.findNavController().popBackStack(R.id.playerFragment, true) + fragment.findNavController().navigate(R.id.playerFragment) } } @@ -396,3 +252,7 @@ class DownloadHandler( } } } + +enum class DownloadAction { + DOWNLOAD, PIN, UNPIN, DELETE +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt new file mode 100644 index 00000000..c0a95bd9 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt @@ -0,0 +1,82 @@ +/* + * CoroutinePatterns.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.util + +import android.os.Handler +import android.os.Looper +import androidx.fragment.app.Fragment +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.moire.ultrasonic.R +import timber.log.Timber + +object CoroutinePatterns { + val loggingExceptionHandler by lazy { + CoroutineExceptionHandler { _, exception -> + Handler(Looper.getMainLooper()).post { + Timber.w(exception) + } + } + } +} + +fun CoroutineScope.executeTaskWithToast( + fragment: Fragment, + task: suspend CoroutineScope.() -> Unit, + successString: () -> String? +): Job { + // Launch the Job + val job = launch(CoroutinePatterns.loggingExceptionHandler, block = task) + + // Setup a handler when the job is done + job.invokeOnCompletion { + val toastString = if (it != null && it !is CancellationException) { + CommunicationError.getErrorMessage(it, fragment.context) + } else { + successString() + } + + // Return early if nothing to post + if (toastString == null) return@invokeOnCompletion + + launch(Dispatchers.Main) { + Util.toast(fragment.context, toastString) + } + } + + return job +} + +fun CoroutineScope.executeTaskWithModalDialog( + fragment: Fragment, + task: suspend CoroutineScope.() -> Unit, + successString: () -> String +) { + // Create the job + val job = executeTaskWithToast(fragment, task, successString) + + // Create the dialog + val builder = InfoDialog.Builder(fragment.requireContext()) + builder.setTitle(R.string.background_task_wait) + builder.setMessage(R.string.background_task_loading) + builder.setOnCancelListener { job.cancel() } + builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() } + val dialog = builder.create() + dialog.show() + + // Add additional handler to close the dialog + job.invokeOnCompletion { + launch(Dispatchers.Main) { + dialog.dismiss() + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index 501573eb..65dbd92b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -181,7 +181,7 @@ object Settings { var firstRunExecuted by BooleanSetting(getKey(R.string.setting_key_first_run_executed), false) val shouldShowArtistPicture - by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), false) + by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), true) @JvmStatic var chatRefreshInterval by StringIntSetting( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt index 73022b81..f6b5b9d2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt @@ -16,7 +16,8 @@ import timber.log.Timber /** * Provides filesystem access abstraction which works - * both on File based paths and Storage Access Framework Uris + * both on File based paths (when using the internal directory for storing media files) + * and Storage Access Framework Uris (when using a custom directory) */ object Storage { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index 80517991..40739ee7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -148,8 +148,8 @@ object Util { if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG } toast!!.show() - } catch (_: Exception) { - // Ignore + } catch (all: Exception) { + Timber.w(all) } } @@ -762,7 +762,7 @@ object Util { fun getPlayListFromTimeline( timeline: Timeline?, - shuffle: Boolean, + isShuffled: Boolean, firstIndex: Int? = null, count: Int? = null ): List { @@ -770,13 +770,13 @@ object Util { if (timeline.windowCount < 1) return emptyList() val playlist: MutableList = mutableListOf() - var i = firstIndex ?: timeline.getFirstWindowIndex(false) + var i = firstIndex ?: timeline.getFirstWindowIndex(isShuffled) if (i == C.INDEX_UNSET) return emptyList() while (i != C.INDEX_UNSET && (count != playlist.count())) { val window = timeline.getWindow(i, Timeline.Window()) playlist.add(window.mediaItem) - i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffle) + i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, isShuffled) } return playlist } diff --git a/ultrasonic/src/main/res/menu/nowplaying.xml b/ultrasonic/src/main/res/menu/nowplaying.xml index 67b7f3ff..39126689 100644 --- a/ultrasonic/src/main/res/menu/nowplaying.xml +++ b/ultrasonic/src/main/res/menu/nowplaying.xml @@ -15,6 +15,13 @@ app:showAsAction="ifRoom|withText" a:title="@string/download.menu_star"/> + + + Guardar lista de reproducción Pantalla apagada Pantalla encendida - Mostrar Álbum + Ir al álbum Aleatorio Modo aleatorio activado Modo aleatorio desactivado @@ -361,7 +361,7 @@ Echa un vistazo a esta música que te comparto desde %s Compartir canciones vía Compartir - Mostrar artista + Ir al artista Portadas de álbumes Múltiples años Mostrar diálogo de confirmación diff --git a/ultrasonic/src/main/res/values-gl/strings.xml b/ultrasonic/src/main/res/values-gl/strings.xml index a6b3daec..86dcf5c1 100644 --- a/ultrasonic/src/main/res/values-gl/strings.xml +++ b/ultrasonic/src/main/res/values-gl/strings.xml @@ -1,2 +1,27 @@ - \ No newline at end of file + + Cargando… + Ocorreu un erro de rede. Por favor comproba a dirección do servidor ou téntao de novo mais tarde. + A API do servidor v%1$s non admite esta función. + Este programa require acceso á rede. Por favor acende a Wi-Fi ou a rede móbil. + Recurso non atopado. Por favor comproba a dirección do servidor. + Non se entende a resposta. Por favor comproba a dirección do servidor. + Erro do certificado HTTPS: %1$s. + Excepción de conexión SSL. Comprobe o certificado do servidor. + Por favor agarde… + Biblioteca + Chat + Reproducindo agora + Reproducir + Pausar + Repetir + Mesturar + Parar + Seguinte + Anterior + Podcast + Non hai canles de Podcasts rexistrados + Podcast + Buscar + Enviar unha mensaxe + \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-pl/strings.xml b/ultrasonic/src/main/res/values-pl/strings.xml index 1b84f3e0..c75f5e26 100644 --- a/ultrasonic/src/main/res/values-pl/strings.xml +++ b/ultrasonic/src/main/res/values-pl/strings.xml @@ -1,7 +1,6 @@ - - Ładowanie… + Ładowanie… Wystąpił błąd sieci. Proszę sprawdzić adres serwera i spróbować później. Server api v%1$s does not support this function. Ta aplikacja wymaga dostępu do sieci. Proszę włączyć wi-fi lub dane komórkowe. @@ -9,7 +8,7 @@ Brak prawidłowej odpowiedzi. Proszę sprawdzić adres serwera. Błąd certyfikatu HTTPS: %1$s. Błąd połączenia SSL. Proszę sprawdzić certyfikat serwera. - Proszę czekać… + Proszę czekać… Zakładki Biblioteka Czat @@ -39,7 +38,7 @@ Zapisz Odepnij Różni artyści - Czy chcesz usunąć %1$s? + Czy chcesz usunąć %1$s Zakładka usunięta. Zakładka ustawiona na %s. Playlista jest pusta @@ -62,7 +61,7 @@ Playlista została zapisana. Błąd zapisu playlisty. Proszę spróbować później. Wprowadź nazwę playlisty: - Trwa zapis playlisty \"%s\"… + Trwa zapis playlisty \"%s\"… Zapisz playlistę Powtarzaj wszystko Powtarzanie wyłączone @@ -96,7 +95,7 @@ Usunięcie playlisty %s nie powiodło się Zakończ Ustawienia - Refresh + Odśwież Biblioteka mediów Media offline Playlisty @@ -172,8 +171,8 @@ Dołącza bitrate i typ pliku do nazwy artysty Ukrywa pliki muzyczne przed innymi aplikacjami. Ukryj pliki - Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android - Proszę wprowadzić prawidłowy URL + Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android. + Proszę wprowadzić prawidłowy URL. Maksymalna ilość wyników - albumy Maksymalna ilość wyników - artyści 112 Kbps @@ -205,10 +204,10 @@ Sieć Inne ustawienia Ustawienia sterowania odtwarzaniem - Resume when a Bluetooth device is connected - Pause when a Bluetooth device is disconnected - All Bluetooth devices - Only audio (A2DP) devices + Wznów po podłączeniu urządzenia Bluetooth + Wstrzymaj, gdy urządzenie Bluetooth jest odłączone + Wszystkie urządzenia Bluetooth + Tylko urządzenia audio (A2DP) Wyłączone Wznawiaj po podłączeniu słuchawek Aplikacja wznowi zatrzymane odtwarzanie po podpięciu słuchawek. @@ -298,18 +297,18 @@ Udostępnianie Wyświetlaj artystę Z różnych lat - Configured servers - Are you sure you want to delete the server? - Editing server + Skonfigurowane serwery + Czy na pewno chcesz usunąć ten serwer\? + Edycja serwera Dodaj serwer - Are you sure you want to leave and lose your changes? - This field is required - Edit + Czy na pewno chcesz wyjść i utracić dokonane zmiany\? + To pole jest wymagane + Edytuj Usuń - Move up - Move down + Przesuń się w górę + Przesuń się w dół Authentication - Advanced settings + Ustawienia zaawansowane %d utwór %d utwory @@ -327,7 +326,148 @@ Okres próbny się zakończył. Brak zgodności wersji. Uaktualnij aplikację Ultrasonic na Androida. Brak zgodności wersji. Uaktualnij serwer Subsonic. - Użyj pięciu gwiazdek dla utworów - + Pokaż okno potwierdzające usunięcie lub odpięcie utworów + Angielski + Pamiętaj o ustawieniu nazwy użytkownika i hasła do usługi Scrobble na serwerze + Użyj metody ID3 także kiedy nie masz połączenia + Zatrzymaj pliki + Wyłączony tryb losowy + Zatrzymaj + Francuski + Czy na pewno chcesz odpiąć zaznaczone pozycje\? + Użyj niestandardowej lokacji pamięci podręcznej + Wybierz wszystko + Włączony tryb losowy + Następne + Chronologicznie + Otwórz ustawienia + Czeski + Wyślij + Zaznaczono %d utworów + Stwórz udostępnienie na serwerze + Niemiecki + Zgłoś błąd + Pobieranie w tle… + 1000 piosenek + Wspierane funkcje + Polski + Artysta + Holenderski + 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 + Usunięto %d utworów + + Powtarzaj + Nic nie jest pobierane + Rosyjski + Byforowanie… + %s - Ustaw serwer + 50 piosenek + Chiński (Chiny) + Nadpisz język + Odtwórz + Domyślne systemowe + Pobrane + Wyświetlaj bitrate i typ pliku + Portugalski (Brazylia) + Plik z logami jest dostępny w %1$s/%2$s + Usunięte pliki z logami. + Naciśnij przycisk odtwarzania na powiadomieniu o mediach, jeśli jest ono nadal obecne, w przeciwnym razie otwórz aplikację, aby rozpocząć odtwarzanie i ponownie podłącz sesję do kontrolera + Włoski + Portugalski + Kolor serwera + Pauza + Pokaż obraz wykonawcy na liście + Tytuł + Czy na pewno chcesz usunąć zaznaczone pozycje\? + Okładka albumu + Album + 500 piosenek + Udostępnianie spowoduje utworzenie go na serwerze i udostępnienie jego adresu URL. Jeśli ta opcja jest wyłączona, udostępniane są tylko szczegóły utworu + Pokaż Obecnie odtwarzane po kliknięciu przycisku Odtwarzaj + Hiszpański + Wymagane jest ponowne uruchomienie aplikacji po zmianie języka + Nie można wznowić odtwarzania + Awatar + Chiński (Tajwan) + Dzień i noc + Czarny + Zmusza to aplikację do wysyłania hasła w postaci niezaszyfrowanej. Przydatne, jeśli serwer Subsonic nie obsługuje nowego interfejsu API uwierzytelniania dla użytkowników. + Pokaż więcej informacji o utworze w sekcji Obecnie odtwarzane (gatunek, rok, przepustowość) + Pokaż szczegóły w sekcji Obecnie odtwarzane + 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 + Odpięto %d utworów + + Użyj odtwarzania sprzętowwego (eksperymentalne) + Jukebox + Uwaga: Brak dostępnych sieci do użycia. +\n Jeżeli używasz danych mobilnych, potrzebne może być włączenie płatnych połączeń w ustawieniach. + Przełącz na Obecnie odtwarzane po rozpoczęciu odtwarzania w widoku multimediów + Odstępy między wyszukaniami + Ilość równocześnie pobieranych piosenek + 100 piosenek + Scrobbluj moje odtworzenia + Jeśli włączysz to ustawienie, będzie ono wyświetlać tylko muzykę pobraną za pomocą Ultrasonic w wersji 4.0 lub nowszej. Wcześniejsze pobrane pliki nie zawierają wymaganych metadanych. Możesz przełączać się między trybami Przypinania i Zapisywania, aby wyzwolić pobieranie brakujących metadanych. + Wyświetla obraz wykonawcy na liście wykonawców, jeśli jest dostępny + Pobieraj tylko podczas połączeń niepłatnuch + Udostępnij obecnie odtwarzaną piosenkę + Pokaż okno potwierdzające + Opcje debugowania + Zapisz logi debugowania do pliku + Powiadomienia są wymagane do odtwarzania multimediów. Możesz przyznać uprawnienie do nich w dowolnym momencie w ustawieniach Androida. + Jedna lub więcej funkcji zostało wyłączonych ponieważ serwer ich nie obsługiwał. +\nMożesz uruchomić ten test ponownie kiedykolwiek. + Serwer demonstracyjny + Użyj systemu pięciu gwiazdek do oceniania utworów zamiast po prostu dodawać lub usuwać utwory z ulubionych. + 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 + Wstawiono %d utworów po bieżącym utworze + + Ultrasonic to darmowy i otwarty klient strumieniowego przesyłania muzyki dla serwerów kompatybilnych z API Subsonic (wersja 1.7.0 lub nowsza). +\n +\nDzięki Ultrasonic możesz łatwo przesyłać strumieniowo lub pobierać muzykę z komputera domowego na telefon za pomocą serwera multimediów kompatybilnego z Subsonic. Oprogramowanie serwera Subsonic wymaga oddzielnej konfiguracji od Ultrasonic. +\n +\nDomyślnie Ultrasonic nie jest skonfigurowane. Po skonfigurowaniu własnego serwera zmień ustawienia serwera, aby połączyć się z komputerem. + Aby używać Ultrasonic z własną muzyką, potrzebujesz własnego serwera. +\n +\n➤ Jeśli chcesz najpierw wypróbować aplikację, możesz teraz dodać serwer demonstracyjny. +\n +\n➤ W przeciwnym razie możesz skonfigurować serwer w ustawieniach. + \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-zh-rCN/strings.xml b/ultrasonic/src/main/res/values-zh-rCN/strings.xml index 82215162..fea8126c 100644 --- a/ultrasonic/src/main/res/values-zh-rCN/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rCN/strings.xml @@ -3,11 +3,11 @@ 加载中… 发生网络错误。请检查服务器地址或稍后重试。 服务端 API v%1$s 不支持此功能。 - 此软件需要连接网络,请打开Wi-Fi或移动网络。 + 此软件需要连接网络,请打开 Wi-Fi 或移动网络。 未找到资源,请检查服务器地址。 - 未知回复内容,请检查服务器地址 - HTTPS 证书错误: %1$s. - SSL连接异常。请检查服务器证书。 + 未知回复内容,请检查服务器地址。 + HTTPS 证书错误:%1$s. + SSL 连接异常。请检查服务器证书。 请稍等… 书签 媒体库 @@ -50,20 +50,20 @@ 取消固定 您真的要删除当前选择吗? 群星 - 确定要删除 %1$s吗 + 确定要删除 %1$s 吗 书签已删除。 - 书签设置为 %s。 + 书签设置在 %s。 未下载任何内容 空的播放列表 - 不允许远程控制. 请在您的服务器上的 Users > Settings 打开点唱机模式。 - 关闭远程控制,音乐将在手机上播放 - 离线模式不支持远程控制 - 打开远程控制,音乐将在服务端播放。 - 远程控制不支持,请升级您的 Subsonic服务器。 + 不允许远程控制. 请在您 Subsonic 服务器上的 用户 > 设置 中打开点唱机模式。 + 已关闭远程控制,音乐将在手机上播放。 + 离线模式不支持远程控制。 + 已打开远程控制,音乐将在服务端播放。 + 远程控制不支持,请升级您的 Subsonic 服务器。 远程音量 均衡器 - 关闭 Jukebox - 开启 Jukebox + 关闭点唱机 + 开启点唱机 歌词 保存播放列表 关闭屏幕常亮 @@ -86,7 +86,7 @@ 均衡器 选择预设 错误 - 默认自动点唱机 + 默认使用点唱机 找不到歌词 系统默认 按艺术家排序 @@ -101,7 +101,7 @@ 艺术家 流派 离线 - %s - 已设置服务器 + %s - 设置服务器 随机 收藏夹 歌曲 @@ -110,13 +110,13 @@ \n \n➤ 如果您想试试此应用, 可以添加试用服务器。 \n -\n➤ 可在 设置中编辑服务器配置信息。 +\n➤ 可在 设置 中编辑服务器配置信息。 欢迎! 点击前往设置 关于 公共 已删除播放列表 %s - 播放列表删除失败%s + 播放列表删除失败 %s 下载 退出 设置 @@ -136,8 +136,9 @@ 搜索 找不到歌曲 已选择 %d 首曲目 - 警告:当前没有可用的网络.\n 如果您要使用移动数据,您需要在设置中允许使用流量进行下载 - 错误:没有SD卡 + 警告:当前没有可用的网络。 +\n 如果您要使用移动数据,您需要在设置中允许使用流量进行下载。 + 错误:没有SD卡。 播放全部 所有文件夹 选择文件夹 @@ -198,11 +199,11 @@ 按光盘编号和曲目编号对歌曲列表进行排序 展示比特率和文件后缀 在艺术家姓名后追加比特率和文件后缀 - 正在播放 - 隐藏来自其他应用的音乐 + 播放时显示正在播放界面 + 隐藏来自其他应用的音乐。 隐藏其他来源 在安卓系统下次扫描音乐时生效。 - 请填写有效的URL。 + 请填写有效的 URL。 最大专辑 最大艺术家 112 Kbps @@ -215,9 +216,9 @@ 64 Kbps 80 Kbps 96 Kbps - 最大比特率-移动网络 + 最大比特率 - 移动网络 不限制 - 最大比特率-WIFI + 最大比特率 - WIFI 最大歌曲 响应手机、耳机和蓝牙设备的媒体按钮 媒体按钮 @@ -249,7 +250,7 @@ 5 首歌 不限制 请记得在服务器上的 Scrobble 服务中设置您的用户名和密码 - Scrobble我的播放列表 + Scrobble 我的播放列表 1 10 100 @@ -278,8 +279,8 @@ 显示曲目编号 显示歌曲时包括曲目编号 测试连接 - Light - Dark + 亮色 + 暗色 Black 主题 允许自签名 HTTPS 证书 @@ -292,7 +293,7 @@ 如果可用,在艺术家列表中显示艺术家图片 视频 仅未计量的网络用于下载媒体 - 仅使用Wi-Fi进行下载 + 仅使用 Wi-Fi 进行下载 %d kbps 0 B 0.00 GB @@ -330,7 +331,7 @@ 保存为默认 评论 有效期 - %s已从播放列表中移除 + %s 已从播放列表中移除 分享播放列表 分享当前曲目 默认分享问候语 @@ -338,14 +339,14 @@ 分享歌曲通过 分享 显示艺术家 - Multiple Years + 数年 调试选项 将调试日志写入文件 日志文件可在 %1$s/%2$s 获取 %3$s 目录中有 %1$s 个日志文件占用了 ~%2$s MB 空间。您想保留这些吗? 保留文件 删除文件 - 删除日志文件 + 删除日志文件。 在后台下载媒体… 配置服务器 您确定要删除此服务器吗? @@ -373,45 +374,45 @@ %d 首曲目 - 已选择 %d 首歌曲进行固定。 + 已选择 %d 首歌曲进行固定 - 已选择要下载 %d 首歌曲。 + 已选择要下载 %d 首歌曲 - 已选择 %d 首歌曲取消固定。 + 已选择 %d 首歌曲取消固定 %d 首歌曲被删除 - 已将 %d 首歌曲添加到播放队列的末尾。 + 已将 %d 首歌曲添加到播放队列的末尾 - 在当前歌曲之后插入了 %d 首歌曲。 + 在当前歌曲之后插入了 %d 首歌曲 - 一般 API 错误: %1$s + 一般 API 错误:%1$s 服务器未发送任何信息 - LDAP用户不支持以token形式授权连接。 - 用户名或密码错误 - 授权失败,请在 Subsonic server 检查用户权限。 + LDAP 用户不支持以 token 形式授权连接。 + 用户名或密码错误。 + 授权失败,请在 Subsonic 服务器上检查用户权限。 缺少必需的参数。 未找到请求的数据。 - 试用期结束 + 试用期结束。 版本不兼容,请升级 Ultrasonic 应用。 - 不兼容的版本。请升级Subsonic 服务。 + 版本不兼容,请升级 Subsonic 服务器。 为歌曲使用五星评分 500 首歌 - 如果您启用此设置,它将只显示您使用 Ultrasonic 4.0 或更高版本下载的音乐。较早的下载没有下载必要的元数据。您可以在 Pin 和 Save 模式之间切换,以触发缺失元数据的下载。 + 如果您启用此设置,它将只显示您使用 Ultrasonic 4.0 或更高版本下载的音乐。较早的下载没有下载必要的元数据。您可以在固定和保存模式之间切换,以触发缺失元数据的下载。 如果媒体通知仍然存在,请按媒体通知中的播放按钮;否则请打开应用程序开始播放,并重新连接会话到控制器 无法恢复播放 头像图片 你真的想取消固定当前选择吗\? 简体中文(中国大陆) 繁体中文(中国台湾) - + 英语 按时间排序 在媒体库中开始播放后切换到正在播放页面 快进/快退间隔 @@ -421,7 +422,7 @@ 50 首歌 100 首歌 1000 首歌 - 这会强制应用程序始终以未加密的方式发送密码。如果 Subsonic 服务器不支持新的用户身份验证 API,则该选项非常有用。 + 这会强制应用程序始终以未加密的方式发送密码。如果此 Subsonic 服务器不支持新的用户身份验证 API,则该选项非常有用。 在正在播放中展示更多歌曲细节(流派,年份,比特率) 在正在播放中展示更多歌曲细节 离线时也使用 ID3 方法 @@ -431,8 +432,21 @@ 需要通知权限才能进行媒体播放。您可以随时在 Android 设置中授予权限。 对歌曲使用五星评级系统,而不是简单的星标/取消星标。 使用硬件回放(实验性) - 尝试使用手机上的媒体解码器芯片来播放媒体。这可以改善电池使用情况。 + 尝试使用手机上的媒体解码器芯片来播放媒体。这可以改善电池使用情况。部分用户报告启用该选项后播放会有问题! 列表 封面 已支持的功能 + 西班牙语 + 法语 + 意大利语 + 俄语 + 荷兰语 + 日与夜 + 匈牙利语 + 波兰语 + 点唱机 + 葡萄牙语 + 捷克语 + 德语 + 葡萄牙语(巴西) \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-zh-rTW/strings.xml b/ultrasonic/src/main/res/values-zh-rTW/strings.xml index b9c989d4..8fdfccfd 100644 --- a/ultrasonic/src/main/res/values-zh-rTW/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rTW/strings.xml @@ -1,6 +1,5 @@ - 載入中… 書籤 媒體庫 @@ -32,7 +31,7 @@ 全選 標題 各式歌手 - 您想刪除 %1$s 嗎? + 您想刪除 %1$s 嗎 書籤已移除。 無下載 播放清單是空的 @@ -130,4 +129,108 @@ 已停用 註記 刪除 - + 顯示專輯 + 簡體中文(中國) + 儲存播放清單 + 書籤設置在 %s。 + 不支援遠端控制,請升級您的 Subsonic 伺服器。 + 播客 + 您真的要取消固定目前選取的項目嗎? + Ultrasonic + SSL 連線異常。請檢查伺服器憑證。 + 固定 + 傳送 + 聊天 + 遠端音量 + 頭像 + 您真的要刪除目前選取的項目嗎? + 無法理解答覆,請檢查伺服器位址。 + 荷蘭語 + 已關閉遠端控制,音樂將在手機上播放。 + 德語 + 請稍候… + 取消固定 + 輸入播放清單名稱: + 依照時間排列 + 正在儲存播放清單 \"%s\"… + 系統預設 + HTTPS 憑證錯誤:%1$s. + 波蘭語 + 沒有已註冊的播客頻道 + 傳送訊息 + 打開熒幕常亮 + 義大利語 + 法語 + 此程式需要連結網路。請打開 Wi-Fi 或行動網路。 + 最高評分 + %s - 設定伺服器 + 俄語 + 西班牙語 + 播客 + 儲存播放清單失敗,請稍後再試。 + 關閉熒幕常亮 + 顯示更多 + 打開點唱機 + 選擇預設 + 歌曲 + 離線模式下無法使用遠端控制。 + 隨機播放 + 確定 + 關閉點唱機 + 未找到歌詞 + 葡萄牙語(巴西) + 全部循環播放 + 找不到資源,請檢查伺服器位址。 + 單曲循環播放 + 匈牙利語 + 前往設定 + 繁體中文(臺灣) + 關閉循環播放 + 預設使用點唱機 + 發生網路錯誤。請檢查伺服器位址或稍後重試。 + 已打開遠端控制,音樂將在伺服器上播放。 + 儲存播放清單 + 捷克語 + 英語 + 葡萄牙語 + 歌曲 + 歡迎! + 已刪除播放清單 %s + 要使用 Ultrasonic 播放您的音樂,需要您 自己的伺服器。 +\n +\n➤ 如果您想嘗試此應用程式, 可以添加一個演示伺服器。 +\n +\n➤ 此外,可在 設定 中配置您的伺服器。 + 播放清單刪除失敗 %s + 沒有匹配的結果,請重試 + 找不到媒體 + 已選取 %d 首曲目 + 伺服器 API v%1$s 不支援此功能。 + 已儲存播放清單。 + 更新了 %s 的播放清單資訊 + 更新 %s 的播放清單資訊失敗 + 刷新 + 離線媒體 + 更新資訊 + 不允許遠端控制。請在您在 Subsonic 伺服器上的 使用者 > 設定 中啟用點唱機模式。 + 目錄快取時間 + 依光碟編號和曲目編號對歌曲清單進行排序 + 清空搜尋記錄 + 聊天訊息刷新時間間隔 + 在藝術家名稱後附加位元速率和檔案後綴 + 外觀 + 歌曲播放完畢後清除書籤 + 依光碟排序歌曲 + 錯誤:無可用的 SD 卡。 + 警告:目前沒有可用的網路。 +\n 如果您要使用行動數據,您需要在設定中允許使用計量付費網路連線下載。 + 播放全部 + 所有資料夾 + 選擇資料夾 + 伺服器上沒有已保存的播放清單 + 隱藏其他來源 + 隱藏來自其他應用程式的音樂檔案。 + 在 Android 系統下次掃描裝置內音樂時生效。 + 播放時顯示正在播放介面 + 在媒體庫介面開始播放後切換到正在播放介面 + \ No newline at end of file diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 36cf28c0..12c534f1 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -71,7 +71,7 @@ Save Playlist Screen Off Screen On - Show Album + Go to Album Shuffle Shuffle mode enabled Shuffle mode disabled @@ -367,7 +367,7 @@ Check out this music I shared from %s Share songs via Share - Show Artist + Go to Artist Album artwork Multiple Years Show confirmation dialog From 98a61954a3a16ad0ce6431ec3444d804917f7b25 Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Thu, 18 May 2023 20:28:13 +0000 Subject: [PATCH 4/5] Release 4.4.1 --- .../metadata/android/en-US/changelogs/120.txt | 10 +++++ gradle.properties | 3 ++ ultrasonic/build.gradle | 4 +- ultrasonic/minify/proguard-retrofit.pro | 41 ++++++++++++++++--- 4 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/120.txt diff --git a/fastlane/metadata/android/en-US/changelogs/120.txt b/fastlane/metadata/android/en-US/changelogs/120.txt new file mode 100644 index 00000000..be4a20f9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/120.txt @@ -0,0 +1,10 @@ +Features: +- This releases focuses on shuffled playback. The view of the playlist will now present itself in the order it will actually play. You can toggle the shuffle mode to create a new order, while the past playback history will be preserved. +- Use Coroutines for triggering the download or playback of music through the context menus +- Enable Artists pictures by Default + +Bug fixes: +- Remove an unhelpful popup that "ID must be set" +- Shuffle mode doesn't always play all tracks +- Shuffle mode starts with the first track most of the time + diff --git a/gradle.properties b/gradle.properties index 7ab3793a..e2c4770f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,3 +20,6 @@ android.nonFinalResIds=true # It can be removed if it makes problems org.gradle.unsafe.configuration-cache=true +# TODO Renable on day (check that Retrofit, Jackson, and Imageloader are working) +android.enableR8.fullMode=false + diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 365c50b7..8213981d 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -9,8 +9,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 119 - versionName "4.4.0" + versionCode 120 + versionName "4.4.1" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk diff --git a/ultrasonic/minify/proguard-retrofit.pro b/ultrasonic/minify/proguard-retrofit.pro index f6dd5b8f..8e85c3d0 100644 --- a/ultrasonic/minify/proguard-retrofit.pro +++ b/ultrasonic/minify/proguard-retrofit.pro @@ -1,10 +1,41 @@ -#### From retrofit +# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and +# EnclosingMethod is required to use InnerClasses. +-keepattributes Signature, InnerClasses, EnclosingMethod -# Retain generic type information for use by reflection by converters and adapters. --keepattributes Signature -# Retain service method parameters. --keepclassmembernames,allowobfuscation interface * { +# Retrofit does reflection on method and parameter annotations. +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations + +# Keep annotation default values (e.g., retrofit2.http.Field.encoded). +-keepattributes AnnotationDefault + +# Retain service method parameters when optimizing. +-keepclassmembers,allowshrinking,allowobfuscation interface * { @retrofit2.http.* ; } + # Ignore annotation used for build tooling. -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement + +# Ignore JSR 305 annotations for embedding nullability information. +-dontwarn javax.annotation.** + +# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. +-dontwarn kotlin.Unit + +# Top-level functions that can only be used by Kotlin. +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy +# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> + +# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). +-keep,allowobfuscation,allowshrinking interface retrofit2.Call +-keep,allowobfuscation,allowshrinking class retrofit2.Response + +# With R8 full mode generic signatures are stripped for classes that are not +# kept. Suspend functions are wrapped in continuations where the type argument +# is used. +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation \ No newline at end of file From eb3aa0d202eeb24cf34a2fc4d0642e8361b21f56 Mon Sep 17 00:00:00 2001 From: tzugen Date: Sat, 3 Jun 2023 11:11:28 +0200 Subject: [PATCH 5/5] Release 4.5.0 --- fastlane/metadata/android/en-US/changelogs/122.txt | 10 ++++++++++ .../metadata/android/en-US/full_description.txt | 14 +++++++------- ultrasonic/build.gradle | 4 ++-- 3 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/122.txt diff --git a/fastlane/metadata/android/en-US/changelogs/122.txt b/fastlane/metadata/android/en-US/changelogs/122.txt new file mode 100644 index 00000000..e691579a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/122.txt @@ -0,0 +1,10 @@ +Features: +- Revamp management of ratings. Tracks can be starred from the notification in Android 13, and the changes will show up everywhere immediately. +- Add a setting to control the maximum bitrate when pinning music (can be used to avoid downloading lossless files like flac). +- Modernize the Jukebox player. +- The hardware keys can be used to set the Jukebox volume. +- The current playlist shows a spinner when loading takes some time + +Bug fixes: +- Request correct bluetooth permission on Android 13 (needed to pause/play on connect) +- Update dependencies (OkHttp, Material) \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 18d5f968..cdec6bb2 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,17 +1,17 @@ Ultrasonic is a Subsonic (and compatible servers) client to Android. You can use Ultrasonic to connect with your server and listen music. Main features: -* Thin -* Fast -* Material theme with dark and light variants +* Small size & fast +* Material You theme with dark and light variants * Multiple server support -* Offline Mode +* Download tracks for offline playback * Bookmarks * Playlists on server -* Random play +* Shuffled playback * Jukebox mode -* Server chat -* And much more!!! +* And much more!! + +Note: Ultrasonic uses semantic release versions. Releases with a zero in the last digit introduce new features or significant changes, all other releases focus on fixing bugs. The source code is available with GPL license in GitLab: https://gitlab.com/ultrasonic/ultrasonic If you have any issue, please post in: https://gitlab.com/ultrasonic/ultrasonic/issues diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 3d72f36b..bc68203f 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -9,8 +9,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 120 - versionName "4.4.1" + versionCode 122 + versionName "4.5.0" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk