Introduce a RatingManager that takes care of receiving and passing ratings...

This commit is contained in:
birdbird 2023-05-07 09:27:24 +00:00
parent 138db03667
commit 2a02c94c8f
12 changed files with 299 additions and 183 deletions

View File

@ -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 {

View File

@ -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) {
@ -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 {

View File

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

View File

@ -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
@ -230,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
@ -467,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
@ -500,6 +540,7 @@ class PlayerFragment :
equalizerMenuItem.isEnabled = isEqualizerAvailable
equalizerMenuItem.isVisible = isEqualizerAvailable
}
val mediaPlayerController = mediaPlayerController
val track = mediaPlayerController.currentMediaItem?.toTrack()
@ -512,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) {
@ -555,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
@ -655,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<SessionResult> {
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
}
@ -1072,8 +1093,6 @@ class PlayerFragment :
}
}
// TODO: It would be a lot nicer if MediaPlayerController would send an event
// when this is necessary instead of updating every time
updateSongRating()
nextButton.isEnabled = mediaPlayerController.canSeekToNext()
@ -1082,7 +1101,6 @@ class PlayerFragment :
@Synchronized
private fun updateSeekBar() {
Timber.i("Calling updateSeekBar")
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
val duration: Int = mediaPlayerController.playerDuration
@ -1233,11 +1251,7 @@ class PlayerFragment :
}
private fun updateSongRating() {
var rating = 0
if (currentSong?.userRating != null) {
rating = currentSong!!.userRating!!
}
val rating = currentSong?.userRating ?: 0
fiveStar1ImageView.setImageResource(if (rating > 0) fullStar else hollowStar)
fiveStar2ImageView.setImageResource(if (rating > 1) fullStar else hollowStar)
@ -1248,8 +1262,15 @@ class PlayerFragment :
private fun setSongRating(rating: Int) {
if (currentSong == null) return
currentSong?.userRating = rating
updateSongRating()
mediaPlayerController.setSongRating(rating)
RxBus.ratingSubmitter.onNext(
RatingUpdate(
currentSong!!.id,
StarRating(5, rating.toFloat())
)
)
}
@SuppressLint("InflateParams")

View File

@ -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<SessionResult> {
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)
}
}

View File

@ -10,7 +10,6 @@ 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
@ -21,28 +20,26 @@ 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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
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
@ -231,11 +228,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")
}
@ -701,52 +708,49 @@ class MediaPlayerController(
controller?.volume = volume
}
fun toggleSongStarred(): ListenableFuture<SessionResult>? {
if (currentMediaItem == null) return null
val song = currentMediaItem!!.toTrack()
return (controller as? MediaController)?.setRating(
HeartRating(!song.starred)
)?.let {
Futures.addCallback(
it,
object : FutureCallback<SessionResult> {
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
mainScope.launch {
withContext(Dispatchers.IO) {
try {
getMusicService().setRating(song.id, rating)
} catch (e: Exception) {
Timber.e(e)
}
}
}
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?
@ -764,7 +768,6 @@ class MediaPlayerController(
* 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.
* @param timeline the timeline to search in.
* @return the index of the window that satisfies the search condition,
* or [C.INDEX_UNSET] if not found.
*/

View File

@ -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<PlaybackStateSerializer>()
private val mediaPlayerController by inject<MediaPlayerController>()
private val imageLoaderProvider: ImageLoaderProvider by inject()
@ -71,6 +72,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
CacheCleaner().clean()
created = true
ratingManager = RatingManager.instance
Timber.i("LifecycleSupport created")
}
@ -187,12 +189,12 @@ class MediaPlayerLifecycleSupport : KoinComponent {
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 -> {
}
}

View File

@ -39,10 +39,10 @@ interface MusicService {
fun getGenres(refresh: Boolean): List<Genre>
@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)

View File

@ -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()
}
}
}

View File

@ -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 {
@ -75,6 +76,18 @@ class RxBus {
val trackDownloadStateObservable: Observable<TrackDownloadState> =
trackDownloadStatePublisher.observeOn(mainThread())
// Sends a RatingUpdate which was just triggered by the user
val ratingSubmitter: PublishSubject<RatingUpdate> =
PublishSubject.create()
val ratingSubmitterObservable: Observable<RatingUpdate> =
ratingSubmitter
// Sends a RatingUpdate which was successfully submitted to the server or database
val ratingPublished: PublishSubject<RatingUpdate> =
PublishSubject.create()
val ratingPublishedObservable: Observable<RatingUpdate> =
ratingPublished
// Commands
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
PublishSubject.create()

View File

@ -15,6 +15,13 @@
app:showAsAction="ifRoom|withText"
a:title="@string/download.menu_star"/>
<item
a:id="@+id/menu_show_artist"
a:title="@string/download.menu_show_artist"/>
<item
a:id="@+id/menu_show_album"
a:title="@string/download.menu_show_album"/>
<item
a:id="@+id/menu_item_share_song"
a:icon="@drawable/ic_menu_share"

View File

@ -71,7 +71,7 @@
<string name="download.menu_save">Save Playlist</string>
<string name="download.menu_screen_off">Screen Off</string>
<string name="download.menu_screen_on">Screen On</string>
<string name="download.menu_show_album">Show Album</string>
<string name="download.menu_show_album">Go to Album</string>
<string name="download.menu_shuffle">Shuffle</string>
<string name="download.menu_shuffle_on">Shuffle mode enabled</string>
<string name="download.menu_shuffle_off">Shuffle mode disabled</string>
@ -367,7 +367,7 @@
<string name="share_default_greeting">Check out this music I shared from %s</string>
<string name="share_via">Share songs via</string>
<string name="menu.share">Share</string>
<string name="download.menu_show_artist">Show Artist</string>
<string name="download.menu_show_artist">Go to Artist</string>
<string name="albumArt">Album artwork</string>
<string name="common_multiple_years">Multiple Years</string>
<string name="settings.show_confirmation_dialog">Show confirmation dialog</string>