mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-14 16:37:16 +03:00
Implement Auto requestMetadata.searchQuery
This commit is contained in:
parent
64d6605c2a
commit
1f9afd92b6
@ -1,8 +1,9 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
|
||||
<ManuallySuppressedIssues>
|
||||
<ID>TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope</ID>
|
||||
<ID>UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder</ID>
|
||||
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file)</ID>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Log file rotated, logging into file %s", file?.name)</ID>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name)</ID>
|
||||
@ -12,7 +13,7 @@
|
||||
<ID>LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
|
||||
<ID>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken, additionalId: String? )</ID>
|
||||
<ID>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
|
||||
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
|
||||
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
|
||||
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
|
||||
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
|
||||
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
|
||||
@ -23,5 +24,6 @@
|
||||
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID>
|
||||
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
|
||||
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
|
||||
</CurrentIssues>
|
||||
</ManuallySuppressedIssues>
|
||||
<CurrentIssues></CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
@ -77,7 +77,8 @@
|
||||
<service android:name=".playback.PlaybackService"
|
||||
android:label="@string/common.appname"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaLibraryService" />
|
||||
@ -86,7 +87,8 @@
|
||||
</service>
|
||||
|
||||
<receiver android:name=".receiver.UltrasonicIntentReceiver"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/>
|
||||
<action android:name="org.moire.ultrasonic.CMD_PLAY"/>
|
||||
@ -128,12 +130,14 @@
|
||||
<provider
|
||||
android:name=".provider.SearchSuggestionProvider"
|
||||
android:authorities="${applicationId}.provider.SearchSuggestionProvider"
|
||||
android:exported="true" />
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedContentProvider" />
|
||||
|
||||
<provider
|
||||
android:name=".provider.AlbumArtContentProvider"
|
||||
android:authorities="${applicationId}.provider.AlbumArtContentProvider"
|
||||
android:exported="true" />
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedContentProvider" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
@ -7,11 +7,13 @@
|
||||
|
||||
package org.moire.ultrasonic.playback
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_SHORT
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
|
||||
@ -33,7 +35,7 @@ import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.guava.await
|
||||
import kotlinx.coroutines.guava.future
|
||||
import org.koin.core.component.KoinComponent
|
||||
@ -93,18 +95,20 @@ private const val SEARCH_LIMIT = 10
|
||||
|
||||
// List of available custom SessionCommands
|
||||
const val SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING"
|
||||
const val PLAY_COMMAND = "play "
|
||||
|
||||
/**
|
||||
* MediaBrowserService implementation for e.g. Android Auto
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) :
|
||||
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
|
||||
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private val serviceJob = Job()
|
||||
private val serviceJob = SupervisorJob()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
|
||||
private val mainScope = CoroutineScope(Dispatchers.Main)
|
||||
|
||||
@ -152,13 +156,15 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
Timber.i("onGetLibraryRoot")
|
||||
return Futures.immediateFuture(
|
||||
LibraryResult.ofItem(
|
||||
buildMediaItem(
|
||||
"Root Folder",
|
||||
MEDIA_ROOT_ID,
|
||||
isPlayable = false,
|
||||
folderType = FOLDER_TYPE_MIXED
|
||||
folderType = FOLDER_TYPE_MIXED,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
|
||||
),
|
||||
params
|
||||
)
|
||||
@ -169,6 +175,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
Timber.i("onConnect")
|
||||
val connectionResult = super.onConnect(session, controller)
|
||||
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
||||
|
||||
@ -189,14 +196,23 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaId: String
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
playFromMediaId(mediaId)
|
||||
Timber.i("onGetItem")
|
||||
|
||||
val tracks = tracksFromMediaId(mediaId)
|
||||
val mediaItem = tracks?.firstOrNull()?.toMediaItem()
|
||||
// TODO:
|
||||
// Create LRU Cache of MediaItems, fill it in the other calls
|
||||
// and retrieve it here.
|
||||
return Futures.immediateFuture(
|
||||
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
)
|
||||
|
||||
if (mediaItem != null) {
|
||||
return Futures.immediateFuture(
|
||||
LibraryResult.ofItem(mediaItem, null)
|
||||
)
|
||||
} else {
|
||||
return Futures.immediateFuture(
|
||||
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
@ -207,7 +223,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
pageSize: Int,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
// TODO: params???
|
||||
Timber.i("onLoadChildren")
|
||||
return onLoadChildren(parentId)
|
||||
}
|
||||
|
||||
@ -217,7 +233,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
|
||||
Timber.i("onCustomCommand")
|
||||
var customCommandFuture: ListenableFuture<SessionResult>? = null
|
||||
|
||||
when (customCommand.customAction) {
|
||||
@ -313,63 +329,80 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
* 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
|
||||
*/
|
||||
@Suppress("MagicNumber", "ComplexMethod")
|
||||
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
|
||||
if (!mediaItems.any()) return Futures.immediateFuture(mediaItems)
|
||||
Timber.i("onAddMediaItems")
|
||||
|
||||
// Try to find out if the requester understands requestMetadata in the mediaItems
|
||||
if (mediaItems.firstOrNull()?.requestMetadata?.mediaUri != null) {
|
||||
val updatedMediaItems = mediaItems.map { mediaItem ->
|
||||
mediaItem.buildUpon()
|
||||
.setUri(mediaItem.requestMetadata.mediaUri)
|
||||
.build()
|
||||
if (mediaItems.isEmpty()) return Futures.immediateFuture(mediaItems)
|
||||
// Return early if its a search
|
||||
if (mediaItems[0].requestMetadata.searchQuery != null)
|
||||
return playFromSearch(mediaItems[0].requestMetadata.searchQuery!!)
|
||||
|
||||
val updatedMediaItems: List<MediaItem> =
|
||||
mediaItems.mapNotNull { mediaItem ->
|
||||
if (mediaItem.requestMetadata.mediaUri != null)
|
||||
mediaItem.buildUpon()
|
||||
.setUri(mediaItem.requestMetadata.mediaUri)
|
||||
.build()
|
||||
else
|
||||
null
|
||||
}
|
||||
return Futures.immediateFuture(updatedMediaItems.toMutableList())
|
||||
|
||||
return if (updatedMediaItems.isNotEmpty()) {
|
||||
Futures.immediateFuture(updatedMediaItems)
|
||||
} else {
|
||||
// Android Auto devices still only use the MediaId to identify the selected Items
|
||||
// They also only select a single item at once
|
||||
val mediaIdParts = mediaItems.first().mediaId.split('|')
|
||||
|
||||
val tracks = when (mediaIdParts.first()) {
|
||||
MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
|
||||
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||
)
|
||||
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
|
||||
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||
)
|
||||
MEDIA_SONG_STARRED_ID -> playStarredSongs()
|
||||
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
|
||||
MEDIA_SONG_RANDOM_ID -> playRandomSongs()
|
||||
MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1])
|
||||
MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1])
|
||||
MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1])
|
||||
MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1])
|
||||
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
|
||||
mediaIdParts[1], mediaIdParts[2]
|
||||
)
|
||||
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
|
||||
else -> null
|
||||
}
|
||||
if (tracks != null) {
|
||||
return Futures.immediateFuture(
|
||||
tracks.map { track -> track.toMediaItem() }
|
||||
.toMutableList()
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback to the original list
|
||||
return Futures.immediateFuture(mediaItems)
|
||||
onAddLegacyAutoItems(mediaItems)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber", "ComplexMethod")
|
||||
private fun onAddLegacyAutoItems(
|
||||
mediaItems: MutableList<MediaItem>
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
val mediaIdParts = mediaItems.first().mediaId.split('|')
|
||||
|
||||
val tracks = when (mediaIdParts.first()) {
|
||||
MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
|
||||
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||
)
|
||||
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
|
||||
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||
)
|
||||
MEDIA_SONG_STARRED_ID -> playStarredSongs()
|
||||
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
|
||||
MEDIA_SONG_RANDOM_ID -> playRandomSongs()
|
||||
MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1])
|
||||
MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1])
|
||||
MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1])
|
||||
MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1])
|
||||
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
|
||||
mediaIdParts[1], mediaIdParts[2]
|
||||
)
|
||||
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (tracks != null) {
|
||||
return Futures.immediateFuture(
|
||||
tracks.map { track -> track.toMediaItem() }
|
||||
.toMutableList()
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback to the original list
|
||||
return Futures.immediateFuture(mediaItems)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "ComplexMethod")
|
||||
fun onLoadChildren(
|
||||
parentId: String,
|
||||
@ -409,26 +442,27 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearch(
|
||||
private fun playFromSearch(
|
||||
query: String,
|
||||
extras: Bundle?,
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
Timber.d("AutoMediaBrowserService onSearch query: %s", query)
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
Timber.i("AutoMediaBrowserService onSearch query: %s", query)
|
||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||
|
||||
// Only accept query with pattern "play [Title]" or "[Title]"
|
||||
// Where [Title]: must be exactly matched
|
||||
// If no media with exact name found, play a random media instead
|
||||
val mediaTitle = if (query.startsWith(PLAY_COMMAND, ignoreCase = true)) {
|
||||
query.drop(PLAY_COMMAND.length)
|
||||
} else {
|
||||
query
|
||||
}
|
||||
|
||||
return serviceScope.future {
|
||||
val criteria = SearchCriteria(query, SEARCH_LIMIT, SEARCH_LIMIT, SEARCH_LIMIT)
|
||||
val criteria = SearchCriteria(mediaTitle, SEARCH_LIMIT, SEARCH_LIMIT, SEARCH_LIMIT)
|
||||
val searchResult = callWithErrorHandling { musicService.search(criteria) }
|
||||
|
||||
// TODO Add More... button to categories
|
||||
if (searchResult != null) {
|
||||
searchResult.artists.map { artist ->
|
||||
mediaItems.add(
|
||||
artist.name ?: "",
|
||||
listOf(MEDIA_ARTIST_ITEM, artist.id, artist.name).joinToString("|"),
|
||||
FOLDER_TYPE_ARTISTS
|
||||
)
|
||||
}
|
||||
|
||||
searchResult.albums.map { album ->
|
||||
mediaItems.add(
|
||||
@ -439,6 +473,15 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
)
|
||||
}
|
||||
|
||||
// TODO Commented out, as there is no playFromArtist function implemented yet.
|
||||
// searchResult.artists.map { artist ->
|
||||
// mediaItems.add(
|
||||
// artist.name ?: "",
|
||||
// listOf(MEDIA_ARTIST_ITEM, artist.id, artist.name).joinToString("|"),
|
||||
// FOLDER_TYPE_ARTISTS
|
||||
// )
|
||||
// }
|
||||
|
||||
searchSongsCache = searchResult.songs
|
||||
searchResult.songs.map { song ->
|
||||
mediaItems.add(
|
||||
@ -448,21 +491,30 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
)
|
||||
}
|
||||
}
|
||||
return@future LibraryResult.ofItemList(mediaItems, null)
|
||||
|
||||
// TODO This just picks the first result and plays it.
|
||||
// We could make this more advanced.
|
||||
val firstItem = mediaItems.first()
|
||||
val tracks = tracksFromMediaId(firstItem.mediaId)
|
||||
Timber.i("Found media id: %s", firstItem.mediaId)
|
||||
val result = tracks?.map { it.toMediaItem() }
|
||||
Timber.i("Result size: %d", result?.size ?: 0)
|
||||
return@future result ?: listOf()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber", "ComplexMethod")
|
||||
private fun playFromMediaId(mediaId: String?) {
|
||||
private fun tracksFromMediaId(mediaId: String?): List<Track>? {
|
||||
Timber.d(
|
||||
"AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s",
|
||||
mediaId
|
||||
)
|
||||
|
||||
if (mediaId == null) return
|
||||
if (mediaId == null) return null
|
||||
val mediaIdParts = mediaId.split('|')
|
||||
|
||||
when (mediaIdParts.first()) {
|
||||
// TODO Media Artist item is missing!!!
|
||||
return when (mediaIdParts.first()) {
|
||||
MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
|
||||
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||
@ -483,6 +535,9 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
mediaIdParts[1], mediaIdParts[2]
|
||||
)
|
||||
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
|
||||
else -> {
|
||||
listOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -503,6 +558,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
R.string.music_library_label,
|
||||
MEDIA_LIBRARY_ID,
|
||||
null,
|
||||
folderType = FOLDER_TYPE_MIXED,
|
||||
icon = R.drawable.ic_library
|
||||
)
|
||||
|
||||
@ -849,13 +905,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
return null
|
||||
}
|
||||
|
||||
private fun playAlbum(id: String, name: String): List<Track>? {
|
||||
private fun playAlbum(id: String, name: String?): List<Track>? {
|
||||
val songs = listSongsInMusicService(id, name)
|
||||
if (songs != null) return songs.getTracks()
|
||||
return null
|
||||
}
|
||||
|
||||
private fun playAlbumSong(id: String, name: String, songId: String): List<Track>? {
|
||||
private fun playAlbumSong(id: String, name: String?, songId: String): List<Track>? {
|
||||
val songs = listSongsInMusicService(id, name)
|
||||
val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId }
|
||||
if (song != null) return listOf(song)
|
||||
@ -1132,7 +1188,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
return null
|
||||
}
|
||||
|
||||
private fun listSongsInMusicService(id: String, name: String): MusicDirectory? {
|
||||
private fun listSongsInMusicService(id: String, name: String?): MusicDirectory? {
|
||||
return serviceScope.future {
|
||||
if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) {
|
||||
callWithErrorHandling { musicService.getAlbumAsDir(id, name, false) }
|
||||
|
@ -8,8 +8,13 @@ package org.moire.ultrasonic.playback
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.C.USAGE_MEDIA
|
||||
@ -29,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.activity.NavigationActivity
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||
@ -66,6 +72,7 @@ class PlaybackService :
|
||||
Timber.i("onCreate called")
|
||||
super.onCreate()
|
||||
initializeSessionAndPlayer()
|
||||
setListener(MediaSessionServiceListener())
|
||||
}
|
||||
|
||||
private fun getWakeModeFlag(): Int {
|
||||
@ -211,10 +218,10 @@ class PlaybackService :
|
||||
private fun getPendingIntentForContent(): PendingIntent {
|
||||
val intent = Intent(this, NavigationActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
var flags = FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// needed starting Android 12 (S = 31)
|
||||
flags = flags or PendingIntent.FLAG_IMMUTABLE
|
||||
flags = flags or FLAG_IMMUTABLE
|
||||
}
|
||||
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
|
||||
return PendingIntent.getActivity(this, 0, intent, flags)
|
||||
@ -236,4 +243,56 @@ class PlaybackService :
|
||||
val context = UApp.applicationContext()
|
||||
UltrasonicAppWidgetProvider.notifyPlayerStateChange(context, isPlaying)
|
||||
}
|
||||
|
||||
private inner class MediaSessionServiceListener : Listener {
|
||||
|
||||
/**
|
||||
* This method is only required to be implemented on Android 12 or above when an attempt is made
|
||||
* by a media controller to resume playback when the {@link MediaSessionService} is in the
|
||||
* background.
|
||||
*/
|
||||
override fun onForegroundServiceStartNotAllowedException() {
|
||||
val notificationManagerCompat = NotificationManagerCompat.from(this@PlaybackService)
|
||||
Util.ensureNotificationChannel(
|
||||
id = NOTIFICATION_CHANNEL_ID,
|
||||
name = NOTIFICATION_CHANNEL_NAME,
|
||||
notificationManager = notificationManagerCompat
|
||||
)
|
||||
val pendingIntent =
|
||||
TaskStackBuilder.create(this@PlaybackService).run {
|
||||
addNextIntent(Intent(this@PlaybackService, NavigationActivity::class.java))
|
||||
|
||||
val immutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
FLAG_IMMUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
getPendingIntent(0, immutableFlag or FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
val builder =
|
||||
NotificationCompat.Builder(this@PlaybackService, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setSmallIcon(R.drawable.media3_notification_small_icon)
|
||||
.setContentTitle(getString(R.string.foreground_exception_title))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle().bigText(
|
||||
getString(R.string.foreground_exception_text)
|
||||
)
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
|
||||
Util.postNotificationIfPermitted(
|
||||
notificationManagerCompat,
|
||||
NOTIFICATION_ID,
|
||||
builder.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic.error"
|
||||
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic error messages"
|
||||
private const val NOTIFICATION_ID = 3009
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import java.io.File
|
||||
import java.util.Locale
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.imageloader.ImageLoader
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
@ -29,9 +30,10 @@ class AlbumArtContentProvider : ContentProvider(), KoinComponent {
|
||||
companion object {
|
||||
fun mapArtworkToContentProviderUri(track: Track?): Uri? {
|
||||
if (track?.coverArt.isNullOrBlank()) return null
|
||||
val domain = UApp.applicationContext().packageName + ".provider.AlbumArtContentProvider"
|
||||
return Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_CONTENT)
|
||||
.authority("org.moire.ultrasonic.provider.AlbumArtContentProvider")
|
||||
.authority(domain)
|
||||
// currently only large files are cached
|
||||
.path(
|
||||
String.format(
|
||||
|
@ -8,8 +8,6 @@
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.net.wifi.WifiManager
|
||||
@ -82,8 +80,15 @@ class DownloadService : Service(), KoinComponent {
|
||||
val supervisor = SupervisorJob()
|
||||
scope = CoroutineScope(Dispatchers.IO + supervisor)
|
||||
|
||||
val notificationManagerCompat = NotificationManagerCompat.from(this)
|
||||
|
||||
// Create Notification Channel
|
||||
createNotificationChannel()
|
||||
Util.ensureNotificationChannel(
|
||||
id = NOTIFICATION_CHANNEL_ID,
|
||||
name = NOTIFICATION_CHANNEL_NAME,
|
||||
importance = 2,
|
||||
notificationManager = notificationManagerCompat
|
||||
)
|
||||
updateNotification()
|
||||
|
||||
if (wifiLock == null) {
|
||||
@ -213,25 +218,6 @@ class DownloadService : Service(), KoinComponent {
|
||||
updateLiveData()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
// The suggested importance of a startForeground service notification is IMPORTANCE_LOW
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
NOTIFICATION_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
channel.lightColor = android.R.color.holo_blue_dark
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
channel.setShowBadge(false)
|
||||
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
// We should use a single notification builder, otherwise the notification may not be updated
|
||||
// Set some values that never change
|
||||
private val notificationBuilder: NotificationCompat.Builder by lazy {
|
||||
@ -252,13 +238,8 @@ class DownloadService : Service(), KoinComponent {
|
||||
val notification = buildForegroundNotification()
|
||||
|
||||
if (isInForeground) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
} else {
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
Util.postNotificationIfPermitted(manager, NOTIFICATION_ID, notification)
|
||||
Timber.v("Updated notification")
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
@ -17,6 +17,7 @@ import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
||||
import androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionResult
|
||||
@ -146,6 +147,20 @@ class MediaPlayerController(
|
||||
}
|
||||
isJukeboxEnabled = false
|
||||
}
|
||||
|
||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||
val timeline: Timeline = controller!!.currentTimeline
|
||||
var windowIndex = timeline.getFirstWindowIndex( /* shuffleModeEnabled= */true)
|
||||
var count = 0
|
||||
Timber.d("Shuffle: windowIndex: $windowIndex, at: $count")
|
||||
while (windowIndex != C.INDEX_UNSET) {
|
||||
count++
|
||||
windowIndex = timeline.getNextWindowIndex(
|
||||
windowIndex, REPEAT_MODE_OFF, /* shuffleModeEnabled= */true
|
||||
)
|
||||
Timber.d("Shuffle: windowIndex: $windowIndex, at: $count")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedMediaItem: MediaItem? = null
|
||||
@ -389,12 +404,18 @@ class MediaPlayerController(
|
||||
}
|
||||
|
||||
if (shuffle) isShufflePlayEnabled = true
|
||||
|
||||
controller?.addMediaItems(insertAt, mediaItems)
|
||||
|
||||
prepare()
|
||||
|
||||
if (autoPlay) play()
|
||||
// Playback doesn't start correctly when the player is in STATE_ENDED.
|
||||
// So we need to call seek before (this is what play(0,0)) does.
|
||||
// We can't just use play(0,0) then all random playlists will start with the first track.
|
||||
// This means that we need to generate the random first track ourselves.
|
||||
if (autoPlay) {
|
||||
val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled) ?: 0
|
||||
play(start)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
@ -7,12 +7,14 @@
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE
|
||||
import androidx.media3.common.StarRating
|
||||
import java.text.DateFormat
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
@ -204,19 +206,21 @@ fun MediaItem.shouldBePinned(): Boolean {
|
||||
* Build a new MediaItem from a list of attributes.
|
||||
* Especially useful to create folder entries in the Auto interface.
|
||||
*/
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Suppress("LongParameterList")
|
||||
fun buildMediaItem(
|
||||
title: String,
|
||||
mediaId: String,
|
||||
isPlayable: Boolean,
|
||||
@MediaMetadata.FolderType folderType: Int,
|
||||
folderType: @MediaMetadata.FolderType Int,
|
||||
album: String? = null,
|
||||
artist: String? = null,
|
||||
genre: String? = null,
|
||||
sourceUri: Uri? = null,
|
||||
imageUri: Uri? = null,
|
||||
starred: Boolean = false,
|
||||
group: String? = null
|
||||
group: String? = null,
|
||||
mediaType: @MediaMetadata.MediaType Int? = null,
|
||||
): MediaItem {
|
||||
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
@ -234,6 +238,14 @@ fun buildMediaItem(
|
||||
metadataBuilder.setArtworkUri(imageUri)
|
||||
}
|
||||
|
||||
if (folderType > FOLDER_TYPE_NONE) {
|
||||
metadataBuilder.setIsBrowsable(true)
|
||||
}
|
||||
|
||||
if (mediaType != null) {
|
||||
metadataBuilder.setMediaType(mediaType)
|
||||
}
|
||||
|
||||
if (group != null) {
|
||||
metadataBuilder.setExtras(
|
||||
Bundle().apply {
|
||||
|
@ -10,6 +10,9 @@ package org.moire.ultrasonic.util
|
||||
import android.Manifest.permission.POST_NOTIFICATIONS
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.app.Service.STOP_FOREGROUND_REMOVE
|
||||
@ -34,6 +37,7 @@ import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.AnyRes
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
@ -511,7 +515,33 @@ object Util {
|
||||
}
|
||||
}
|
||||
|
||||
fun ensurePermissionToPostNotification(fragment: ComponentActivity) {
|
||||
fun ensureNotificationChannel(
|
||||
id: String,
|
||||
name: String,
|
||||
importance: Int? = null,
|
||||
notificationManager: NotificationManagerCompat
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
// The suggested importance of a startForeground service notification is IMPORTANCE_LOW
|
||||
val channel = NotificationChannel(
|
||||
id,
|
||||
name,
|
||||
importance ?: NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
|
||||
channel.lightColor = android.R.color.holo_blue_dark
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
channel.setShowBadge(false)
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun ensurePermissionToPostNotification(
|
||||
fragment: ComponentActivity,
|
||||
onGranted: (() -> Unit)? = null
|
||||
) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
applicationContext(),
|
||||
POST_NOTIFICATIONS,
|
||||
@ -527,9 +557,27 @@ object Util {
|
||||
}
|
||||
|
||||
requestPermissionLauncher.launch(POST_NOTIFICATIONS)
|
||||
} else {
|
||||
// Execute the closure
|
||||
if (onGranted != null) {
|
||||
onGranted()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun postNotificationIfPermitted(
|
||||
notificationManagerCompat: NotificationManagerCompat,
|
||||
id: Int,
|
||||
notification: Notification
|
||||
) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
applicationContext(),
|
||||
POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
) {
|
||||
notificationManagerCompat.notify(id, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("DEPRECATION")
|
||||
fun getVersionName(context: Context): String? {
|
||||
|
@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<string name="background_task.loading">Loading…</string>
|
||||
<string name="background_task.network_error">A network error occurred. Please check the server address or try again later.</string>
|
||||
<string name="background_task.unsupported_api">Server API v%1$s does not support this function.</string>
|
||||
@ -394,11 +395,13 @@
|
||||
<string name="server_editor.advanced">Advanced settings</string>
|
||||
<string name="server_editor.disabled_feature">One or more features were disabled because the server doesn\'t support them.\nYou can run this test again anytime.</string>
|
||||
<string name="server_menu.demo">Demo Server</string>
|
||||
|
||||
<string name="about.webpage">Visit webpage</string>
|
||||
<string name="about.report">Report a bug</string>
|
||||
<string name="about.text"><b>Ultrasonic</b> is a free and open-source music streaming Android client for Subsonic API (version 1.7.0 or higher) compatible servers.\n\nWith <b>Ultrasonic</b> you can easily stream or download music from your home computer to your Android phone using your Subsonic compatible media server. The Subsonic server software requires additional configuration separate from Ultrasonic.\n\nBy default, Ultrasonic is not configured. Once you\'ve set up your own server, please change the server configuration so that it connects to your own computer.</string>
|
||||
<string name="about.webpage.url" translatable="false">https://ultrasonic.gitlab.io/</string>
|
||||
<string name="about.report.url" translatable="false">https://gitlab.com/ultrasonic/ultrasonic/issues</string>
|
||||
|
||||
<plurals name="select_album_n_songs">
|
||||
<item quantity="one">%d song</item>
|
||||
<item quantity="other">%d songs</item>
|
||||
@ -427,6 +430,7 @@
|
||||
<item quantity="one">%d song inserted after current song</item>
|
||||
<item quantity="other">%d songs inserted after current song</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Generic API error: %1$s</string>
|
||||
<string name="api.subsonic.generic.no.message">no message given from server</string>
|
||||
@ -438,6 +442,7 @@
|
||||
<string name="api.subsonic.trial_period_is_over">Trial period is over.</string>
|
||||
<string name="api.subsonic.upgrade_client">Incompatible versions. Please upgrade Ultrasonic Android app.</string>
|
||||
<string name="api.subsonic.upgrade_server">Incompatible versions. Please upgrade Subsonic server.</string>
|
||||
|
||||
<!-- Subsonic features -->
|
||||
<string name="settings.five_star_rating_title">Use five star rating for songs</string>
|
||||
<string name="settings.five_star_rating_description">Use five star rating system for songs instead of simply starring/unstarring items.</string>
|
||||
@ -447,4 +452,10 @@
|
||||
<string name="grid_view">Cover</string>
|
||||
<string name="supported_server_features">Supported features</string>
|
||||
<string name="jukebox">Jukebox</string>
|
||||
</resources>
|
||||
<string name="foreground_exception_title">Playback cannot be resumed</string>
|
||||
<string name="foreground_exception_text">Press on the play button on the media notification if it
|
||||
is still present, otherwise please open the app to start the playback and re-connect the session
|
||||
to the controller</string>
|
||||
|
||||
|
||||
</resources>
|
||||
|
Loading…
x
Reference in New Issue
Block a user