mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-07-21 10:51:55 +03:00
Compare commits
4 Commits
71168983b6
...
2df8d049d0
Author | SHA1 | Date | |
---|---|---|---|
|
2df8d049d0 | ||
|
0643b1bd1c | ||
|
2b1291ae51 | ||
|
5ec0d8a96b |
@ -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"
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
@ -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(
|
||||||
|
@ -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(
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user