Compare commits

...

4 Commits

Author SHA1 Message Date
birdbird
2df8d049d0 Merge branch 'Binder' into 'develop'
Refactor rating controls in Session

Closes #1235

See merge request ultrasonic/ultrasonic!1020
2023-05-20 14:32:27 +00:00
birdbird
0643b1bd1c Refactor rating controls in Session 2023-05-20 14:32:27 +00:00
birdbird
2b1291ae51 Merge branch 'userdata' into 'develop'
Add hasFragileUserData=true

See merge request ultrasonic/ultrasonic!1021
2023-05-20 13:38:13 +00:00
birdbird
5ec0d8a96b Add hasFragileUserData=true 2023-05-20 13:38:12 +00:00
10 changed files with 197 additions and 205 deletions

View File

@ -22,6 +22,7 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_descriptor" android:fullBackupContent="@xml/backup_descriptor"
android:hasFragileUserData="true" tools:targetApi="q"
android:dataExtractionRules="@xml/backup_rules" android:dataExtractionRules="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"

View File

@ -1,39 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util;
import android.os.Binder;
/**
* @author Sindre Mehus
*/
public class SimpleServiceBinder<S> extends Binder
{
private final S service;
public SimpleServiceBinder(S service)
{
this.service = service;
}
public S getService()
{
return service;
}
}

View File

@ -11,6 +11,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.media3.common.HeartRating import androidx.media3.common.HeartRating
import androidx.media3.common.StarRating
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.progressindicator.CircularProgressIndicator import com.google.android.material.progressindicator.CircularProgressIndicator
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -139,7 +140,17 @@ class TrackViewHolder(val view: View) :
updateStatus(it.state, it.progress) updateStatus(it.state, it.progress)
} }
// Timber.v("Setting song done") // Listen for rating updates
rxBusSubscription!! += RxBus.ratingPublishedObservable.subscribe {
// Ignore updates which are not for the current song
if (it.id != song.id) return@subscribe
if (it.rating is HeartRating) {
updateSingleStar(it.rating.isHeart)
} else if (it.rating is StarRating) {
updateFiveStars(it.rating.starRating.toInt())
}
}
} }
// This is called when the Holder is recycled and receives a new Song // This is called when the Holder is recycled and receives a new Song

View File

@ -225,7 +225,7 @@ class PlayerFragment :
fiveStar5ImageView = view.findViewById(R.id.song_five_star_5) fiveStar5ImageView = view.findViewById(R.id.song_five_star_5)
} }
@Suppress("LongMethod", "DEPRECATION") @Suppress("LongMethod")
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
cancellationToken = CancellationToken() cancellationToken = CancellationToken()
@ -235,6 +235,7 @@ class PlayerFragment :
val width: Int val width: Int
val height: Int val height: Int
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bounds = windowManager.currentWindowMetrics.bounds val bounds = windowManager.currentWindowMetrics.bounds
width = bounds.width() width = bounds.width()

View File

@ -15,8 +15,11 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
@ -55,8 +58,7 @@ import timber.log.Timber
/** /**
* Initiates a search on the media library and displays the results * Initiates a search on the media library and displays the results
* * TODO: Switch to material3 class
* TODO: Implement the search field without using the deprecated OptionsMenu calls
*/ */
class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent { class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
private var searchResult: SearchResult? = null private var searchResult: SearchResult? = null
@ -80,7 +82,13 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
cancellationToken = CancellationToken() cancellationToken = CancellationToken()
setTitle(this, R.string.search_title) setTitle(this, R.string.search_title)
setHasOptionsMenu(true)
// Register our options menu
(requireActivity() as MenuHost).addMenuProvider(
menuProvider,
viewLifecycleOwner,
Lifecycle.State.RESUMED
)
listModel.searchResult.observe( listModel.searchResult.observe(
viewLifecycleOwner viewLifecycleOwner
@ -141,12 +149,24 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
} }
/** /**
* This method creates the search bar above the recycler view * This provide creates the search bar above the recycler view
*/ */
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { private val menuProvider: MenuProvider = object : MenuProvider {
override fun onPrepareMenu(menu: Menu) {
setupOptionsMenu(menu)
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.search, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return true
}
}
fun setupOptionsMenu(menu: Menu) {
val activity = activity ?: return val activity = activity ?: return
val searchManager = activity.getSystemService(Context.SEARCH_SERVICE) as SearchManager val searchManager = activity.getSystemService(Context.SEARCH_SERVICE) as SearchManager
inflater.inflate(R.menu.search, menu)
val searchItem = menu.findItem(R.id.search_item) val searchItem = menu.findItem(R.id.search_item)
searchView = searchItem.actionView as SearchView searchView = searchItem.actionView as SearchView
val searchableInfo = searchManager.getSearchableInfo(requireActivity().componentName) val searchableInfo = searchManager.getSearchableInfo(requireActivity().componentName)
@ -275,7 +295,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
id = item.id, id = item.id,
name = item.name, name = item.name,
parentId = item.id, parentId = item.id,
isArtist = (item is Artist) isArtist = false
) )
} else { } else {
SearchFragmentDirections.searchToAlbumsList( SearchFragmentDirections.searchToAlbumsList(

View File

@ -12,8 +12,11 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -114,7 +117,13 @@ open class TrackCollectionFragment(
setupButtons(view) setupButtons(view)
registerForContextMenu(listView!!) registerForContextMenu(listView!!)
setHasOptionsMenu(true)
// Register our options menu
(requireActivity() as MenuHost).addMenuProvider(
menuProvider,
viewLifecycleOwner,
Lifecycle.State.RESUMED
)
// Create a View Manager // Create a View Manager
viewManager = LinearLayoutManager(this.context) viewManager = LinearLayoutManager(this.context)
@ -257,8 +266,8 @@ open class TrackCollectionFragment(
} }
} }
override fun onPrepareOptionsMenu(menu: Menu) { private val menuProvider: MenuProvider = object : MenuProvider {
super.onPrepareOptionsMenu(menu) override fun onPrepareMenu(menu: Menu) {
playAllButton = menu.findItem(R.id.select_album_play_all) playAllButton = menu.findItem(R.id.select_album_play_all)
if (playAllButton != null) { if (playAllButton != null) {
@ -272,27 +281,25 @@ open class TrackCollectionFragment(
} }
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.select_album, menu) inflater.inflate(R.menu.select_album, menu)
super.onCreateOptionsMenu(menu, inflater)
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onMenuItemSelected(item: MenuItem): Boolean {
val itemId = item.itemId if (item.itemId == R.id.select_album_play_all) {
if (itemId == R.id.select_album_play_all) {
playAll() playAll()
return true return true
} else if (itemId == R.id.menu_item_share) { } else if (item.itemId == R.id.menu_item_share) {
shareHandler.createShare( shareHandler.createShare(
this, getSelectedSongs(), this@TrackCollectionFragment, getSelectedSongs(),
refreshListView, cancellationToken!!, refreshListView, cancellationToken!!,
navArgs.id navArgs.id
) )
return true return true
} }
return false return false
} }
}
override fun onDestroyView() { override fun onDestroyView() {
cancellationToken!!.cancel() cancellationToken!!.cancel()
@ -379,21 +386,18 @@ open class TrackCollectionFragment(
private fun selectAllOrNone() { private fun selectAllOrNone() {
val someUnselected = viewAdapter.selectedSet.size < childCount val someUnselected = viewAdapter.selectedSet.size < childCount
selectAll(someUnselected)
selectAll(someUnselected, true)
} }
private fun selectAll(selected: Boolean, toast: Boolean) { private fun selectAll(selected: Boolean) {
var selectedCount = viewAdapter.selectedSet.size * -1 var selectedCount = viewAdapter.selectedSet.size * -1
selectedCount += viewAdapter.setSelectionStatusOfAll(selected) selectedCount += viewAdapter.setSelectionStatusOfAll(selected)
// Display toast: N tracks selected // Display toast: N tracks selected
if (toast) {
val toastResId = R.string.select_album_n_selected val toastResId = R.string.select_album_n_selected
Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0))) Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0)))
} }
}
@Synchronized @Synchronized
fun triggerButtonUpdate(selection: List<Track> = getSelectedSongs()) { fun triggerButtonUpdate(selection: List<Track> = getSelectedSongs()) {
@ -575,7 +579,7 @@ open class TrackCollectionFragment(
setTitle(R.string.main_videos) setTitle(R.string.main_videos)
listModel.getVideos(refresh2) listModel.getVideos(refresh2)
} else if (id == null || getRandomTracks) { } else if (id == null || getRandomTracks) {
// There seems to be a bug in ViewPager when resuming the Actitivy that subfragments // There seems to be a bug in ViewPager when resuming the Activity that sub-fragments
// arguments are empty. If we have no id, just show some random tracks // arguments are empty. If we have no id, just show some random tracks
setTitle(R.string.main_songs_random) setTitle(R.string.main_songs_random)
listModel.getRandom(size, append) listModel.getRandom(size, append)
@ -636,10 +640,6 @@ open class TrackCollectionFragment(
R.id.song_menu_download -> { R.id.song_menu_download -> {
downloadBackground(false, songs) downloadBackground(false, songs)
} }
R.id.select_album_play_all -> {
// TODO: Why is this being handled here?!
playAll()
}
R.id.song_menu_share -> { R.id.song_menu_share -> {
if (item is Track) { if (item is Track) {
shareHandler.createShare( shareHandler.createShare(

View File

@ -9,8 +9,6 @@ package org.moire.ultrasonic.playback
import android.annotation.SuppressLint import android.annotation.SuppressLint
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.HeartRating
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
@ -20,6 +18,7 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
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.Rating import androidx.media3.common.Rating
import androidx.media3.session.CommandButton
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
@ -27,7 +26,6 @@ import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult import androidx.media3.session.SessionResult
import androidx.media3.session.SessionResult.RESULT_SUCCESS 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
@ -49,7 +47,6 @@ import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RatingManager import org.moire.ultrasonic.service.RatingManager
import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.buildMediaItem import org.moire.ultrasonic.util.buildMediaItem
import org.moire.ultrasonic.util.toMediaItem import org.moire.ultrasonic.util.toMediaItem
@ -92,7 +89,6 @@ private const val DISPLAY_LIMIT = 100
private const val SEARCH_LIMIT = 10 private const val SEARCH_LIMIT = 10
// List of available custom SessionCommands // List of available custom SessionCommands
const val SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING"
const val PLAY_COMMAND = "play " const val PLAY_COMMAND = "play "
/** /**
@ -119,6 +115,24 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
private val isOffline get() = ActiveServerProvider.isOffline() private val isOffline get() = ActiveServerProvider.isOffline()
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
private var customCommands: List<CommandButton>
internal var customLayout = ImmutableList.of<CommandButton>()
init {
customCommands =
listOf(
// This button is used for an unstarred track, and its action will star the track
getHeartCommandButton(
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY)
),
// This button is used for an starred track, and its action will unstar the track
getHeartCommandButton(
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
)
)
customLayout = ImmutableList.of(customCommands[0])
}
/** /**
* Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link * Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link
* MediaBrowser#getLibraryRoot(LibraryParams)}. * MediaBrowser#getLibraryRoot(LibraryParams)}.
@ -176,11 +190,10 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
val connectionResult = super.onConnect(session, controller) val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
/* for (commandButton in customCommands) {
* TODO: Currently we need to create a custom session command, see https://github.com/androidx/media/issues/107 // Add custom command to available session commands.
* When this issue is fixed we should be able to remove this method again commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
*/ }
availableSessionCommands.add(SessionCommand(SESSION_CUSTOM_SET_RATING, Bundle()))
return MediaSession.ConnectionResult.accept( return MediaSession.ConnectionResult.accept(
availableSessionCommands.build(), availableSessionCommands.build(),
@ -188,6 +201,28 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
) )
} }
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
if (!customLayout.isEmpty() && controller.controllerVersion != 0) {
// Let Media3 controller (for instance the MediaNotificationProvider)
// know about the custom layout right after it connected.
session.setCustomLayout(customLayout)
}
}
private fun getHeartCommandButton(sessionCommand: SessionCommand): CommandButton {
val willHeart =
(sessionCommand.customAction == PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON)
return CommandButton.Builder()
.setDisplayName("Love")
.setIconResId(
if (willHeart) R.drawable.ic_star_hollow
else R.drawable.ic_star_full
)
.setSessionCommand(sessionCommand)
.setEnabled(true)
.build()
}
override fun onGetItem( override fun onGetItem(
session: MediaLibraryService.MediaLibrarySession, session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
@ -201,12 +236,12 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
// Create LRU Cache of MediaItems, fill it in the other calls // Create LRU Cache of MediaItems, fill it in the other calls
// and retrieve it here. // and retrieve it here.
if (mediaItem != null) { return if (mediaItem != null) {
return Futures.immediateFuture( Futures.immediateFuture(
LibraryResult.ofItem(mediaItem, null) LibraryResult.ofItem(mediaItem, null)
) )
} else { } else {
return Futures.immediateFuture( Futures.immediateFuture(
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
) )
} }
@ -234,40 +269,13 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
var customCommandFuture: ListenableFuture<SessionResult>? = null var customCommandFuture: ListenableFuture<SessionResult>? = null
when (customCommand.customAction) { when (customCommand.customAction) {
SESSION_CUSTOM_SET_RATING -> { PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> {
/* customCommandFuture = onSetRating(session, controller, HeartRating(true))
* It is currently not possible to edit a MediaItem after creation so the isRated value updateCustomHeartButton(session, true)
* is stored in the track.starred value
* See https://github.com/androidx/media/issues/33
*/
val track = mediaPlayerManager.currentMediaItem?.toTrack()
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
// TODO: This is a looping reference
libraryService.onUpdateNotification(session)
}
override fun onFailure(t: Throwable) {
Toast.makeText(
mediaPlayerManager.context,
"There was an error updating the rating",
LENGTH_SHORT
).show()
}
},
MainThreadExecutor()
)
} }
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
customCommandFuture = onSetRating(session, controller, HeartRating(false))
updateCustomHeartButton(session, false)
} }
else -> { else -> {
Timber.d( Timber.d(
@ -281,19 +289,21 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
return customCommandFuture return customCommandFuture
return super.onCustomCommand(session, controller, customCommand, args) return super.onCustomCommand(session, controller, customCommand, args)
} }
override fun onSetRating( override fun onSetRating(
session: MediaSession, session: MediaSession,
controller: MediaSession.ControllerInfo, controller: MediaSession.ControllerInfo,
rating: Rating rating: Rating
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
if (session.player.currentMediaItem != null) val mediaItem = session.player.currentMediaItem
if (mediaItem != null) {
mediaItem.toTrack().starred = (rating as HeartRating).isHeart
return onSetRating( return onSetRating(
session, session,
controller, controller,
session.player.currentMediaItem!!.mediaId, mediaItem.mediaId,
rating rating
) )
}
return super.onSetRating(session, controller, rating) return super.onSetRating(session, controller, rating)
} }
@ -303,6 +313,9 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaId: String, mediaId: String,
rating: Rating rating: Rating
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
// TODO: Through this methods it is possible to set a rating on an arbitrary MediaItem.
// Right now the ratings are submitted, yet the underlying track is only updated when
// coming from the other onSetRating(session, controller, rating)
return serviceScope.future { return serviceScope.future {
Timber.i(controller.packageName) Timber.i(controller.packageName)
// This function even though its declared in AutoMediaBrowserCallback.kt is // This function even though its declared in AutoMediaBrowserCallback.kt is
@ -324,7 +337,6 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
* and thereby customarily it is required to rebuild it.. * and thereby customarily it is required to rebuild it..
* See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error * See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error
*/ */
override fun onAddMediaItems( override fun onAddMediaItems(
mediaSession: MediaSession, mediaSession: MediaSession,
controller: MediaSession.ControllerInfo, controller: MediaSession.ControllerInfo,
@ -1276,4 +1288,15 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
null null
} }
} }
fun updateCustomHeartButton(
session: MediaSession,
isHeart: Boolean
) {
val command = if (isHeart) customCommands[1] else customCommands[0]
// Change the custom layout to contain the right heart button
customLayout = ImmutableList.of(command)
// Send the updated custom layout to controllers.
session.setCustomLayout(customLayout)
}
} }

View File

@ -7,79 +7,22 @@
package org.moire.ultrasonic.playback package org.moire.ultrasonic.playback
import android.content.Context import android.content.Context
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.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.util.toTrack
@UnstableApi @UnstableApi
class CustomNotificationProvider(ctx: Context) : class CustomNotificationProvider(ctx: Context) :
DefaultMediaNotificationProvider(ctx), DefaultMediaNotificationProvider(ctx),
KoinComponent { KoinComponent {
/* // By default the skip buttons are not shown in compact view.
* It is currently not possible to edit a MediaItem after creation so the isRated value // We add the COMMAND_KEY_COMPACT_VIEW_INDEX to show them
* is stored in the track.starred value. See https://github.com/androidx/media/issues/33 // See also: https://github.com/androidx/media/issues/410
* TODO: Once the bug is fixed remove this circular reference!
*/
private val mediaPlayerManager by inject<MediaPlayerManager>()
override fun addNotificationActions(
mediaSession: MediaSession,
mediaButtons: ImmutableList<CommandButton>,
builder: NotificationCompat.Builder,
actionFactory: MediaNotification.ActionFactory
): IntArray {
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 = mediaPlayerManager.currentMediaItem?.toTrack()?.starred?.let {
HeartRating(
it
)
}
if (rating is HeartRating) {
tmp.add(
CommandButton.Builder()
.setDisplayName("Love")
.setIconResId(
if (rating.isHeart) R.drawable.ic_star_full
else R.drawable.ic_star_hollow
)
.setSessionCommand(
SessionCommand(
SESSION_CUSTOM_SET_RATING,
HeartRating(rating.isHeart).toBundle()
)
)
.setExtras(HeartRating(rating.isHeart).toBundle())
.setEnabled(true)
.build()
)
}
return super.addNotificationActions(
mediaSession,
ImmutableList.copyOf((mediaButtons + tmp)),
builder,
actionFactory
)
}
override fun getMediaButtons( override fun getMediaButtons(
session: MediaSession, session: MediaSession,
playerCommands: Player.Commands, playerCommands: Player.Commands,

View File

@ -68,7 +68,7 @@ class PlaybackService :
private var equalizer: EqualizerController? = null private var equalizer: EqualizerController? = null
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
private lateinit var librarySessionCallback: MediaLibrarySession.Callback private lateinit var librarySessionCallback: AutoMediaBrowserCallback
private var rxBusSubscription = CompositeDisposable() private var rxBusSubscription = CompositeDisposable()
@ -132,6 +132,13 @@ class PlaybackService :
setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext())) setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext()))
// TODO: Remove minor code duplication with updateBackend()
val desiredBackend = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
MediaPlayerManager.PlayerBackend.JUKEBOX
} else {
MediaPlayerManager.PlayerBackend.LOCAL
}
player = if (activeServerProvider.getActiveServer().jukeboxByDefault) { player = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
Timber.i("Jukebox enabled by default") Timber.i("Jukebox enabled by default")
getJukeboxPlayer() getJukeboxPlayer()
@ -139,6 +146,8 @@ class PlaybackService :
getLocalPlayer() getLocalPlayer()
} }
actualBackend = desiredBackend
// Create browser interface // Create browser interface
librarySessionCallback = AutoMediaBrowserCallback(this) librarySessionCallback = AutoMediaBrowserCallback(this)
@ -148,6 +157,11 @@ class PlaybackService :
.setBitmapLoader(ArtworkBitmapLoader()) .setBitmapLoader(ArtworkBitmapLoader())
.build() .build()
if (!librarySessionCallback.customLayout.isEmpty()) {
// Send custom layout to legacy session.
mediaLibrarySession.setCustomLayout(librarySessionCallback.customLayout)
}
// Set a listener to update the API client when the active server has changed // Set a listener to update the API client when the active server has changed
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe { rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
// Set the player wake mode // Set the player wake mode
@ -209,6 +223,7 @@ class PlaybackService :
player.addListener(listener) player.addListener(listener)
mediaLibrarySession.player = player mediaLibrarySession.player = player
actualBackend = newBackend actualBackend = newBackend
} }
@ -281,7 +296,14 @@ class PlaybackService :
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateWidgetTrack(mediaItem?.toTrack()) // Since we cannot update the metadata of the media item after creation,
// we cannot set change the rating on it
// Therefore the track must be our source of truth
val track = mediaItem?.toTrack()
if (track != null) {
updateCustomHeartButton(track.starred)
}
updateWidgetTrack(track)
cacheNextSongs() cacheNextSongs()
} }
@ -291,6 +313,10 @@ class PlaybackService :
} }
} }
private fun updateCustomHeartButton(isHeart: Boolean) {
librarySessionCallback.updateCustomHeartButton(mediaLibrarySession, isHeart)
}
private fun cacheNextSongs() { private fun cacheNextSongs() {
if (actualBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) return if (actualBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) return
Timber.d("PlaybackService caching the next songs") Timber.d("PlaybackService caching the next songs")
@ -394,6 +420,10 @@ class PlaybackService :
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic.error" private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic.error"
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic error messages" private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic error messages"
const val CUSTOM_COMMAND_TOGGLE_HEART_ON =
"org.moire.ultrasonic.HEART_ON"
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF =
"org.moire.ultrasonic.HEART_OFF"
private const val NOTIFICATION_ID = 3009 private const val NOTIFICATION_ID = 3009
} }
} }

View File

@ -11,6 +11,7 @@ import android.app.Notification
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Binder
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
@ -39,7 +40,6 @@ import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile import org.moire.ultrasonic.util.FileUtil.getPartialFile
import org.moire.ultrasonic.util.FileUtil.getPinnedFile import org.moire.ultrasonic.util.FileUtil.getPinnedFile
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.SimpleServiceBinder
import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
@ -452,3 +452,5 @@ class DownloadService : Service(), KoinComponent {
} }
} }
} }
class SimpleServiceBinder<S>(val service: S) : Binder()