Notification heart rating (#140)

This commit is contained in:
eddyizm 2025-10-05 13:01:44 -07:00 committed by GitHub
commit 539920965e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 338 additions and 79 deletions

View file

@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.model
import android.net.Uri
import android.os.Bundle
import androidx.annotation.Keep
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.RequestMetadata
import androidx.media3.common.MediaMetadata
@ -243,6 +244,13 @@ class SessionMediaItem() {
.setAlbumTitle(album)
.setArtist(artist)
.setArtworkUri(artworkUri)
.setUserRating(HeartRating(starred != null))
.setSupportedCommands(
listOf(
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
)
)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)

View file

@ -117,6 +117,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
actionDeleteDownloadStorage();
actionKeepScreenOn();
actionAutoDownloadLyrics();
actionMiniPlayerHeart();
bindMediaService();
actionAppEqualizer();
@ -358,6 +359,21 @@ public class SettingsFragment extends PreferenceFragmentCompat {
});
}
private void actionMiniPlayerHeart() {
SwitchPreference preference = findPreference("mini_shuffle_button_visibility");
if (preference == null) {
return;
}
preference.setChecked(Preferences.showShuffleInsteadOfHeart());
preference.setOnPreferenceChangeListener((pref, newValue) -> {
if (newValue instanceof Boolean) {
Preferences.setShuffleInsteadOfHeart((Boolean) newValue);
}
return true;
});
}
private void actionAutoDownloadLyrics() {
SwitchPreference preference = findPreference("auto_download_lyrics");
if (preference == null) {

View file

@ -116,4 +116,13 @@ object Constants {
const val HOME_SECTOR_RECENTLY_ADDED = "HOME_SECTOR_RECENTLY_ADDED"
const val HOME_SECTOR_PINNED_PLAYLISTS = "HOME_SECTOR_PINNED_PLAYLISTS"
const val HOME_SECTOR_SHARED = "HOME_SECTOR_SHARED"
const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = "android.media3.session.demo.SHUFFLE_ON"
const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "android.media3.session.demo.SHUFFLE_OFF"
const val CUSTOM_COMMAND_TOGGLE_HEART_ON = "android.media3.session.demo.HEART_ON"
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = "android.media3.session.demo.HEART_OFF"
const val CUSTOM_COMMAND_TOGGLE_HEART_LOADING = "android.media3.session.demo.HEART_LOADING"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = "android.media3.session.demo.REPEAT_ONE"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL"
}

View file

@ -8,6 +8,7 @@ import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.HeartRating;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
@ -16,6 +17,7 @@ import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
@ -83,6 +85,13 @@ public class MappingUtil {
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setArtworkUri(artworkUri)
.setUserRating(new HeartRating(media.getStarred() != null))
.setSupportedCommands(
ImmutableList.of(
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
)
)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)

View file

@ -73,6 +73,7 @@ object Preferences {
private const val LAST_INSTANT_MIX = "last_instant_mix"
private const val EQUALIZER_ENABLED = "equalizer_enabled"
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
@JvmStatic
fun getServer(): String? {
@ -359,6 +360,16 @@ object Preferences {
).apply()
}
@JvmStatic
fun showShuffleInsteadOfHeart(): Boolean {
return App.getInstance().preferences.getBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, false)
}
@JvmStatic
fun setShuffleInsteadOfHeart(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, enabled).apply()
}
@JvmStatic
fun showServerUnreachableDialog(): Boolean {
return App.getInstance().preferences.getLong(

View file

@ -88,6 +88,9 @@
<string name="error_required">Required</string>
<string name="error_server_prefix">http or https prefix required</string>
<string name="exo_download_notification_channel_name">Downloads</string>
<string name="exo_controls_heart_off_description">Toggle Heart off</string>
<string name="exo_controls_heart_on_description">Toggle Heart on</string>
<string name="cast_expanded_controller_loading">Loading…</string>
<string name="filter_info_selection">Select two or more filters</string>
<string name="filter_title">Filter</string>
<string name="filter_artist">Filter artists</string>
@ -332,6 +335,8 @@
<string name="settings_queue_syncing_countdown">Sync timer</string>
<string name="settings_queue_syncing_summary">If enabled, the user will have the ability to save their play queue and will have the ability to load state when opening the application.</string>
<string name="settings_queue_syncing_title">Sync play queue for this user [Not Fully Baked]</string>
<string name="settings_show_mini_shuffle_button">Show Shuffle button</string>
<string name="settings_show_mini_shuffle_button_summary">If enabled, show the shuffle button, remove the heart in the mini player</string>
<string name="settings_radio">Show radio</string>
<string name="settings_radio_summary">If enabled, show the radio section. Restart the app for it to take full effect.</string>
<string name="settings_auto_download_lyrics">Auto download lyrics</string>

View file

@ -97,6 +97,13 @@
android:defaultValue="true"
android:summary="@string/settings_music_directory_summary"
android:key="music_directory_section_visibility" />
<SwitchPreference
android:title="@string/settings_show_mini_shuffle_button"
android:defaultValue="false"
android:summary="@string/settings_show_mini_shuffle_button_summary"
android:key="mini_shuffle_button_visibility" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_title_data">

View file

@ -2,21 +2,42 @@ package com.cappielloantonio.tempo.service
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.annotation.OptIn
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.Rating
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionError
import androidx.media3.session.SessionResult
import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.AutomotiveRepository
import com.cappielloantonio.tempo.subsonic.base.ApiResponse
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_LOADING
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
import com.google.common.collect.ImmutableList
import com.cappielloantonio.tempo.util.Preferences
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
open class MediaLibrarySessionCallback(
context: Context,
@ -28,82 +49,245 @@ open class MediaLibrarySessionCallback(
MediaBrowserTree.initialize(automotiveRepository)
}
private val shuffleCommandButtons: List<CommandButton> = listOf(
CommandButton.Builder()
private val customCommandToggleShuffleModeOn = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
)
).setIconResId(R.drawable.exo_icon_shuffle_off).build(),
).setIconResId(R.drawable.exo_icon_shuffle_off).build()
CommandButton.Builder()
private val customCommandToggleShuffleModeOff = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
)
).setIconResId(R.drawable.exo_icon_shuffle_on).build()
)
private val repeatCommandButtons: List<CommandButton> = listOf(
CommandButton.Builder()
private val customCommandToggleRepeatModeOff = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_off)
.build(),
CommandButton.Builder()
.build()
private val customCommandToggleRepeatModeOne = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_one)
.build(),
CommandButton.Builder()
.build()
private val customCommandToggleRepeatModeAll = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_all)
.build()
)
private val customLayoutCommandButtons: List<CommandButton> =
shuffleCommandButtons + repeatCommandButtons
private val customCommandToggleHeartOn = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_heart_on_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY
)
)
.setIconResId(R.drawable.ic_favorite)
.build()
private val customCommandToggleHeartOff = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_heart_off_description))
.setSessionCommand(
SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
)
.setIconResId(R.drawable.ic_favorites_outlined)
.build()
// Fake Command while waiting for like update command
private val customCommandToggleHeartLoading = CommandButton.Builder()
.setDisplayName(context.getString(R.string.cast_expanded_controller_loading))
.setSessionCommand(
SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_LOADING, Bundle.EMPTY)
)
.setIconResId(R.drawable.ic_bookmark_sync)
.build()
private val customLayoutCommandButtons = listOf(
customCommandToggleShuffleModeOn,
customCommandToggleShuffleModeOff,
customCommandToggleRepeatModeOff,
customCommandToggleRepeatModeOne,
customCommandToggleRepeatModeAll,
customCommandToggleHeartOn,
customCommandToggleHeartOff,
customCommandToggleHeartLoading,
)
@OptIn(UnstableApi::class)
val mediaNotificationSessionCommands =
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.also { builder ->
(shuffleCommandButtons + repeatCommandButtons).forEach { commandButton ->
customLayoutCommandButtons.forEach { commandButton ->
commandButton.sessionCommand?.let { builder.add(it) }
}
}.build()
fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
val shuffle = shuffleCommandButtons[if (player.shuffleModeEnabled) 1 else 0]
val repeat = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> repeatCommandButtons[1]
Player.REPEAT_MODE_ALL -> repeatCommandButtons[2]
else -> repeatCommandButtons[0]
}
return ImmutableList.of(shuffle, repeat)
}
@OptIn(UnstableApi::class)
override fun onConnect(
session: MediaSession, controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
session.player.addListener(object : Player.Listener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
updateMediaNotificationCustomLayout(session)
}
override fun onRepeatModeChanged(repeatMode: Int) {
updateMediaNotificationCustomLayout(session)
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
updateMediaNotificationCustomLayout(session)
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateMediaNotificationCustomLayout(session)
}
})
// FIXME: I'm not sure this if is required anymore
if (session.isMediaNotificationController(controller) || session.isAutomotiveController(
controller
) || session.isAutoCompanionController(controller)
) {
val customLayout = buildCustomLayout(session.player)
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(mediaNotificationSessionCommands)
.setCustomLayout(customLayout).build()
.setCustomLayout(buildCustomLayout(session.player))
.build()
}
return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build()
}
// Update the mediaNotification after some changes
@OptIn(UnstableApi::class)
private fun updateMediaNotificationCustomLayout(
session: MediaSession,
isRatingPending: Boolean = false
) {
session.setCustomLayout(
session.mediaNotificationControllerInfo!!,
buildCustomLayout(session.player, isRatingPending)
)
}
private fun buildCustomLayout(player: Player, isRatingPending: Boolean = false): ImmutableList<CommandButton> {
val customLayout = mutableListOf<CommandButton>()
val showShuffle = Preferences.showShuffleInsteadOfHeart()
if (!showShuffle) {
if (player.currentMediaItem != null && !isRatingPending) {
// Heart button
if ((player.mediaMetadata.userRating as HeartRating?)?.isHeart == true) {
customLayout.add(customCommandToggleHeartOff)
} else {
customLayout.add(customCommandToggleHeartOn)
}
}
} else {
customLayout.add(
if (player.shuffleModeEnabled) customCommandToggleShuffleModeOff else customCommandToggleShuffleModeOn
)
}
// Add repeat button
val repeatButton = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> customCommandToggleRepeatModeOne
Player.REPEAT_MODE_ALL -> customCommandToggleRepeatModeAll
else -> customCommandToggleRepeatModeOff
}
customLayout.add(repeatButton)
return ImmutableList.copyOf(customLayout)
}
// Setting rating without a mediaId will set the currently listened mediaId
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
rating: Rating
): ListenableFuture<SessionResult> {
return onSetRating(session, controller, session.player.currentMediaItem!!.mediaId, rating)
}
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
mediaId: String,
rating: Rating
): ListenableFuture<SessionResult> {
val isStaring = (rating as HeartRating).isHeart
val networkCall = if (isStaring)
App.getSubsonicClientInstance(false)
.mediaAnnotationClient
.star(mediaId, null, null)
else
App.getSubsonicClientInstance(false)
.mediaAnnotationClient
.unstar(mediaId, null, null)
return CallbackToFutureAdapter.getFuture { completer ->
networkCall.enqueue(object : Callback<ApiResponse?> {
@OptIn(UnstableApi::class)
override fun onResponse(
call: Call<ApiResponse?>,
response: Response<ApiResponse?>
) {
if (response.isSuccessful) {
// Search if the media item in the player should be updated
for (i in 0 until session.player.mediaItemCount) {
val mediaItem = session.player.getMediaItemAt(i)
if (mediaItem.mediaId == mediaId) {
val newMetadata = mediaItem.mediaMetadata.buildUpon()
.setUserRating(HeartRating(isStaring)).build()
session.player.replaceMediaItem(
i,
mediaItem.buildUpon().setMediaMetadata(newMetadata).build()
)
}
}
updateMediaNotificationCustomLayout(session)
completer.set(SessionResult(SessionResult.RESULT_SUCCESS))
} else {
updateMediaNotificationCustomLayout(session)
completer.set(
SessionResult(
SessionError(
response.code(),
response.message()
)
)
)
}
}
@OptIn(UnstableApi::class)
override fun onFailure(call: Call<ApiResponse?>, t: Throwable) {
updateMediaNotificationCustomLayout(session)
completer.set(
SessionResult(
SessionError(
SessionError.ERROR_UNKNOWN,
"An error as occurred"
)
)
)
}
})
}
}
@OptIn(UnstableApi::class)
override fun onCustomCommand(
session: MediaSession,
@ -111,9 +295,23 @@ open class MediaLibrarySessionCallback(
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
val mediaItemId = args.getString(
MediaConstants.EXTRA_KEY_MEDIA_ID,
session.player.currentMediaItem?.mediaId
)
when (customCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> {
session.player.shuffleModeEnabled = true
updateMediaNotificationCustomLayout(session)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> {
session.player.shuffleModeEnabled = false
updateMediaNotificationCustomLayout(session)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
@ -123,17 +321,32 @@ open class MediaLibrarySessionCallback(
else -> Player.REPEAT_MODE_OFF
}
session.player.repeatMode = nextMode
}
else -> return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
}
session.setCustomLayout(
session.mediaNotificationControllerInfo!!,
buildCustomLayout(session.player)
)
updateMediaNotificationCustomLayout(session)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
CUSTOM_COMMAND_TOGGLE_HEART_ON,
CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
val currentRating = session.player.mediaMetadata.userRating as? HeartRating
val isCurrentlyLiked = currentRating?.isHeart ?: false
val newLikedState = !isCurrentlyLiked
updateMediaNotificationCustomLayout(
session,
isRatingPending = true // Show loading state
)
return onSetRating(session, controller, HeartRating(newLikedState))
}
else -> return Futures.immediateFuture(
SessionResult(
SessionError(
SessionError.ERROR_NOT_SUPPORTED,
customCommand.customAction
)
)
)
}
}
override fun onGetLibraryRoot(
session: MediaLibraryService.MediaLibrarySession,
@ -186,17 +399,4 @@ open class MediaLibrarySessionCallback(
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
return MediaBrowserTree.search(query)
}
companion object {
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
"android.media3.session.demo.SHUFFLE_ON"
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
"android.media3.session.demo.SHUFFLE_OFF"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
"android.media3.session.demo.REPEAT_OFF"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
"android.media3.session.demo.REPEAT_ONE"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
"android.media3.session.demo.REPEAT_ALL"
}
}

View file

@ -219,16 +219,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
mediaLibrarySession.setCustomLayout(
librarySessionCallback.buildCustomLayout(player)
)
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
mediaLibrarySession.setCustomLayout(
librarySessionCallback.buildCustomLayout(player)
)
}
})
}