From 2a02c94c8fc5db8445d130ca24c49f624b7a59ae Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Sun, 7 May 2023 09:27:24 +0000 Subject: [PATCH] Introduce a RatingManager that takes care of receiving and passing ratings... --- .../ultrasonic/adapters/AlbumRowDelegate.kt | 34 ++--- .../ultrasonic/adapters/TrackViewHolder.kt | 52 +++----- .../org/moire/ultrasonic/data/RatingUpdate.kt | 16 +++ .../ultrasonic/fragment/PlayerFragment.kt | 119 ++++++++++-------- .../playback/AutoMediaBrowserCallback.kt | 31 +++-- .../service/MediaPlayerController.kt | 101 +++++++-------- .../service/MediaPlayerLifecycleSupport.kt | 14 ++- .../moire/ultrasonic/service/MusicService.kt | 4 +- .../moire/ultrasonic/service/RatingManager.kt | 87 +++++++++++++ .../org/moire/ultrasonic/service/RxBus.kt | 13 ++ ultrasonic/src/main/res/menu/nowplaying.xml | 7 ++ ultrasonic/src/main/res/values/strings.xml | 4 +- 12 files changed, 299 insertions(+), 183 deletions(-) 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 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/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 605b66d6..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) { @@ -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/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 8797d48f..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 @@ -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 { - 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") 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/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index c421a122..611a2c4c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -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? { - 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 - 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. */ 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 c05805e5..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") } @@ -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 -> { } } 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 f4b62de0..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 { @@ -75,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/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"/> + + + 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