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 ccebba77..c1962a79 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 761b8f08..8707e8b5 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 79a61d42..aeb74cbb 100755
--- a/gradlew
+++ b/gradlew
@@ -85,9 +85,6 @@ done
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
-
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -197,6 +194,10 @@ if "$cygwin" || "$msys" ; then
done
fi
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle
index e367497a..365c50b7 100644
--- a/ultrasonic/build.gradle
+++ b/ultrasonic/build.gradle
@@ -9,8 +9,8 @@ android {
defaultConfig {
applicationId "org.moire.ultrasonic"
- versionCode 117
- versionName "4.3.4"
+ versionCode 119
+ versionName "4.4.0"
minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk
@@ -34,7 +34,7 @@ android {
minifyEnabled false
multiDexEnabled true
testCoverageEnabled true
- applicationIdSuffix ".debug"
+ applicationIdSuffix '.debug'
}
}
@@ -56,6 +56,7 @@ android {
buildFeatures {
viewBinding true
dataBinding true
+ buildConfig true
}
compileOptions {
@@ -136,7 +137,6 @@ dependencies {
testImplementation libs.kotlinJunit
testImplementation libs.kluent
testImplementation libs.mockito
- testImplementation libs.mockitoInline
testImplementation libs.mockitoKotlin
testImplementation libs.robolectric
diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml
index 8be19a15..a9b35248 100644
--- a/ultrasonic/src/main/AndroidManifest.xml
+++ b/ultrasonic/src/main/AndroidManifest.xml
@@ -7,7 +7,6 @@
-
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