Merge pull request #770 from Maxmystere/notification/add-rating

Add song rating to notification
This commit is contained in:
birdbird 2022-07-05 19:31:18 +02:00 committed by GitHub
commit 6c6227ce41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 272 additions and 40 deletions

View File

@ -153,6 +153,8 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
star.setImageDrawable(imageHelper.starHollowDrawable) star.setImageDrawable(imageHelper.starHollowDrawable)
song.starred = false song.starred = false
} }
// Should this be done here ?
Thread { Thread {
val musicService = MusicServiceFactory.getMusicService() val musicService = MusicServiceFactory.getMusicService()
try { try {

View File

@ -33,17 +33,22 @@ import android.widget.LinearLayout
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener import android.widget.SeekBar.OnSeekBarChangeListener
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import android.widget.ViewFlipper import android.widget.ViewFlipper
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.media3.common.HeartRating
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.session.SessionResult
import androidx.navigation.Navigation import androidx.navigation.Navigation
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -747,26 +752,32 @@ class PlayerFragment :
if (currentSong == null) return true if (currentSong == null) return true
val isStarred = currentSong!!.starred val isStarred = currentSong!!.starred
val id = currentSong!!.id
if (isStarred) { mediaPlayerController.controller?.setRating(
starMenuItem.icon = hollowStar HeartRating(!isStarred)
currentSong!!.starred = false )?.let {
} else { Futures.addCallback(
starMenuItem.icon = fullStar it,
currentSong!!.starred = true object : FutureCallback<SessionResult> {
override fun onSuccess(result: SessionResult?) {
if (isStarred) {
starMenuItem.icon = hollowStar
currentSong!!.starred = false
} else {
starMenuItem.icon = fullStar
currentSong!!.starred = true
}
}
override fun onFailure(t: Throwable) {
Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT)
.show()
}
},
this.executorService
)
} }
Thread {
val musicService = getMusicService()
try {
if (isStarred) {
musicService.unstar(id, null, null)
} else {
musicService.star(id, null, null)
}
} catch (all: Exception) {
Timber.e(all)
}
}.start()
return true return true
} }
R.id.menu_item_bookmark_set -> { R.id.menu_item_bookmark_set -> {

View File

@ -9,6 +9,9 @@ package org.moire.ultrasonic.playback
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS
@ -18,10 +21,17 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Rating
import androidx.media3.session.LibraryResult import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession 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.collect.ImmutableList
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -41,6 +51,7 @@ import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
@ -80,16 +91,18 @@ private const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM"
private const val DISPLAY_LIMIT = 100 private const val DISPLAY_LIMIT = 100
private const val SEARCH_LIMIT = 10 private const val SEARCH_LIMIT = 10
// List of available custom SessionCommands
const val SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING"
/** /**
* MediaBrowserService implementation for e.g. Android Auto * MediaBrowserService implementation for e.g. Android Auto
*/ */
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember") @Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
class AutoMediaBrowserCallback(var player: Player) : class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) :
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent { MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
private val mediaPlayerController by inject<MediaPlayerController>() private val mediaPlayerController by inject<MediaPlayerController>()
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
private val musicService = MusicServiceFactory.getMusicService()
private val serviceJob = Job() private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
@ -99,6 +112,7 @@ class AutoMediaBrowserCallback(var player: Player) :
private var randomSongsCache: List<Track>? = null private var randomSongsCache: List<Track>? = null
private var searchSongsCache: List<Track>? = null private var searchSongsCache: List<Track>? = null
private val musicService get() = MusicServiceFactory.getMusicService()
private val isOffline get() = ActiveServerProvider.isOffline() private val isOffline get() = ActiveServerProvider.isOffline()
private val useId3Tags get() = Settings.shouldUseId3Tags private val useId3Tags get() = Settings.shouldUseId3Tags
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
@ -150,6 +164,25 @@ class AutoMediaBrowserCallback(var player: Player) :
) )
} }
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
/*
* TODO: Currently we need to create a custom session command, see https://github.com/androidx/media/issues/107
* When this issue is fixed we should be able to remove this method again
*/
availableSessionCommands.add(SessionCommand(SESSION_CUSTOM_SET_RATING, Bundle()))
return MediaSession.ConnectionResult.accept(
availableSessionCommands.build(),
connectionResult.availablePlayerCommands
)
}
override fun onGetItem( override fun onGetItem(
session: MediaLibraryService.MediaLibrarySession, session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
@ -177,6 +210,103 @@ class AutoMediaBrowserCallback(var player: Player) :
return onLoadChildren(parentId) return onLoadChildren(parentId)
} }
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
var customCommandFuture: ListenableFuture<SessionResult>? = null
when (customCommand.customAction) {
SESSION_CUSTOM_SET_RATING -> {
/*
* It is currently not possible to edit a MediaItem after creation so the isRated value
* is stored in the track.starred value
* See https://github.com/androidx/media/issues/33
*/
val track = mediaPlayerController.currentPlayingLegacy?.track
if (track != null) {
customCommandFuture = onSetRating(
session,
controller,
HeartRating(!track.starred)
)
Futures.addCallback(
customCommandFuture,
object : FutureCallback<SessionResult> {
override fun onSuccess(result: SessionResult) {
track.starred = !track.starred
// This needs to be called on the main Thread
libraryService.onUpdateNotification(session)
}
override fun onFailure(t: Throwable) {
Toast.makeText(
mediaPlayerController.context,
"There was an error updating the rating",
LENGTH_SHORT
).show()
}
},
MainThreadExecutor()
)
}
}
else -> {
Timber.d(
"CustomCommand not recognized %s with extra %s",
customCommand.customAction,
customCommand.customExtras.toString()
)
}
}
if (customCommandFuture != null)
return customCommandFuture
return super.onCustomCommand(session, controller, customCommand, args)
}
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
rating: Rating
): ListenableFuture<SessionResult> {
if (session.player.currentMediaItem != null)
return onSetRating(
session,
controller,
session.player.currentMediaItem!!.mediaId,
rating
)
return super.onSetRating(session, controller, rating)
}
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
mediaId: String,
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)
}
}
/* /*
* For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer, * For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer,
* and thereby customarily it is required to rebuild it.. * and thereby customarily it is required to rebuild it..
@ -1076,6 +1206,7 @@ class AutoMediaBrowserCallback(var player: Player) :
album = track.album, album = track.album,
artist = track.artist, artist = track.artist,
genre = track.genre, genre = track.genre,
starred = track.starred
) )
} }
@ -1090,6 +1221,7 @@ class AutoMediaBrowserCallback(var player: Player) :
genre: String? = null, genre: String? = null,
sourceUri: Uri? = null, sourceUri: Uri? = null,
imageUri: Uri? = null, imageUri: Uri? = null,
starred: Boolean = false
): MediaItem { ): MediaItem {
val metadata = val metadata =
MediaMetadata.Builder() MediaMetadata.Builder()
@ -1097,6 +1229,7 @@ class AutoMediaBrowserCallback(var player: Player) :
.setTitle(title) .setTitle(title)
.setArtist(artist) .setArtist(artist)
.setGenre(genre) .setGenre(genre)
.setUserRating(HeartRating(starred))
.setFolderType(folderType) .setFolderType(folderType)
.setIsPlayable(isPlayable) .setIsPlayable(isPlayable)
.setArtworkUri(imageUri) .setArtworkUri(imageUri)

View File

@ -9,15 +9,29 @@ package org.moire.ultrasonic.playback
import android.content.Context import android.content.Context
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.media3.common.HeartRating
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaNotification import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.service.MediaPlayerController
@UnstableApi @UnstableApi
class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) { class MediaNotificationProvider(context: Context) :
DefaultMediaNotificationProvider(context), KoinComponent {
/*
* It is currently not possible to edit a MediaItem after creation so the isRated value
* is stored in the track.starred value. See https://github.com/androidx/media/issues/33
* TODO: Once the bug is fixed remove this circular reference!
*/
private val mediaPlayerController by inject<MediaPlayerController>()
override fun addNotificationActions( override fun addNotificationActions(
mediaSession: MediaSession, mediaSession: MediaSession,
@ -25,7 +39,43 @@ class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProv
builder: NotificationCompat.Builder, builder: NotificationCompat.Builder,
actionFactory: MediaNotification.ActionFactory actionFactory: MediaNotification.ActionFactory
): IntArray { ): IntArray {
return super.addNotificationActions(mediaSession, mediaButtons, builder, actionFactory) val tmp: MutableList<CommandButton> = mutableListOf()
/*
* TODO:
* It is currently not possible to edit a MediaItem after creation so the isRated value
* is stored in the track.starred value
* See https://github.com/androidx/media/issues/33
*/
val rating = mediaPlayerController.currentPlayingLegacy?.track?.starred?.let {
HeartRating(
it
)
}
if (rating is HeartRating) {
tmp.add(
CommandButton.Builder()
.setDisplayName("Love")
.setIconResId(
if (rating.isHeart) R.drawable.ic_star_full_dark
else R.drawable.ic_star_hollow_dark
)
.setSessionCommand(
SessionCommand(
SESSION_CUSTOM_SET_RATING,
HeartRating(rating.isHeart).toBundle()
)
)
.setExtras(HeartRating(rating.isHeart).toBundle())
.setEnabled(true)
.build()
)
}
return super.addNotificationActions(
mediaSession,
mediaButtons + tmp,
builder,
actionFactory
)
} }
override fun getMediaButtons( override fun getMediaButtons(

View File

@ -111,7 +111,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
player.experimentalSetOffloadSchedulingEnabled(true) player.experimentalSetOffloadSchedulingEnabled(true)
// Create browser interface // Create browser interface
librarySessionCallback = AutoMediaBrowserCallback(player) librarySessionCallback = AutoMediaBrowserCallback(player, this)
// This will need to use the AutoCalls // This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)

View File

@ -9,13 +9,18 @@ package org.moire.ultrasonic.service
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.widget.Toast
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionResult
import androidx.media3.session.SessionToken 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.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -34,6 +39,7 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import timber.log.Timber import timber.log.Timber
@ -579,23 +585,30 @@ class MediaPlayerController(
if (legacyPlaylistManager.currentPlaying == null) return if (legacyPlaylistManager.currentPlaying == null) return
val song = legacyPlaylistManager.currentPlaying!!.track val song = legacyPlaylistManager.currentPlaying!!.track
Thread { controller?.setRating(
val musicService = getMusicService() HeartRating(!song.starred)
try { ).let {
if (song.starred) { Futures.addCallback(
musicService.unstar(song.id, null, null) it,
} else { object : FutureCallback<SessionResult> {
musicService.star(song.id, null, null) override fun onSuccess(result: SessionResult?) {
} // Trigger an update
} catch (all: Exception) { // TODO Update Metadata of MediaItem...
Timber.e(all) // localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
} song.starred = !song.starred
}.start() }
// Trigger an update override fun onFailure(t: Throwable) {
// TODO Update Metadata of MediaItem... Toast.makeText(
// localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) context,
song.starred = !song.starred "There was an error updating the rating",
Toast.LENGTH_SHORT
).show()
}
},
MainThreadExecutor()
)
}
} }
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
@ -668,6 +681,7 @@ fun Track.toMediaItem(): MediaItem {
.setArtist(artist) .setArtist(artist)
.setAlbumTitle(album) .setAlbumTitle(album)
.setAlbumArtist(artist) .setAlbumArtist(artist)
.setUserRating(HeartRating(starred))
.build() .build()
val mediaItem = MediaItem.Builder() val mediaItem = MediaItem.Builder()

View File

@ -0,0 +1,22 @@
/*
* MainThreadExecutor.java
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.os.Handler
import android.os.Looper
import java.util.concurrent.Executor
/*
* Executor for running Futures on the main thread
* See https://stackoverflow.com/questions/52642246/how-to-get-executor-for-main-thread-on-api-level-28
*/
class MainThreadExecutor : Executor {
private val handler = Handler(Looper.getMainLooper())
override fun execute(r: Runnable) {
handler.post(r)
}
}