mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-15 08:50:35 +03:00
Merge branch 'ratingManager' into 'develop'
Introduce a RatingManager that takes care of receiving and passing ratings... See merge request ultrasonic/ultrasonic!981
This commit is contained in:
commit
5da9a2819c
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
)
|
@ -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")
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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 -> {
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user