Implement Auto requestMetadata.searchQuery

This commit is contained in:
birdbird 2023-03-24 19:13:18 +00:00
parent 64d6605c2a
commit 1f9afd92b6
10 changed files with 317 additions and 121 deletions

View File

@ -1,8 +1,9 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version="1.0" ?>
<SmellBaseline> <SmellBaseline>
<ManuallySuppressedIssues/> <ManuallySuppressedIssues>
<CurrentIssues> <ID>TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope</ID>
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.&lt;no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID> <ID>UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder</ID>
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.&lt;no name provided&gt;$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("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("Log file rotated, logging into file %s", file?.name)</ID>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("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: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: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>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array&lt;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&lt;ServerSetting&gt;, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -&gt; Unit), private val serverEditRequestedCallback: ((Int) -&gt; Unit) )</ID>
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID> <ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID> <ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID> <ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
@ -23,5 +24,6 @@
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID> <ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID>
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID> <ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID> <ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
</CurrentIssues> </ManuallySuppressedIssues>
<CurrentIssues></CurrentIssues>
</SmellBaseline> </SmellBaseline>

View File

@ -77,7 +77,8 @@
<service android:name=".playback.PlaybackService" <service android:name=".playback.PlaybackService"
android:label="@string/common.appname" android:label="@string/common.appname"
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback"
android:exported="true"> android:exported="true"
tools:ignore="ExportedService">
<intent-filter> <intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" /> <action android:name="androidx.media3.session.MediaLibraryService" />
@ -86,7 +87,8 @@
</service> </service>
<receiver android:name=".receiver.UltrasonicIntentReceiver" <receiver android:name=".receiver.UltrasonicIntentReceiver"
android:exported="true"> android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter> <intent-filter>
<action android:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/> <action android:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/>
<action android:name="org.moire.ultrasonic.CMD_PLAY"/> <action android:name="org.moire.ultrasonic.CMD_PLAY"/>
@ -128,12 +130,14 @@
<provider <provider
android:name=".provider.SearchSuggestionProvider" android:name=".provider.SearchSuggestionProvider"
android:authorities="${applicationId}.provider.SearchSuggestionProvider" android:authorities="${applicationId}.provider.SearchSuggestionProvider"
android:exported="true" /> android:exported="true"
tools:ignore="ExportedContentProvider" />
<provider <provider
android:name=".provider.AlbumArtContentProvider" android:name=".provider.AlbumArtContentProvider"
android:authorities="${applicationId}.provider.AlbumArtContentProvider" android:authorities="${applicationId}.provider.AlbumArtContentProvider"
android:exported="true" /> android:exported="true"
tools:ignore="ExportedContentProvider" />
</application> </application>
</manifest> </manifest>

View File

@ -7,11 +7,13 @@
package org.moire.ultrasonic.playback package org.moire.ultrasonic.playback
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT 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.FOLDER_TYPE_ALBUMS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED 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 com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.await
import kotlinx.coroutines.guava.future import kotlinx.coroutines.guava.future
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -93,18 +95,20 @@ 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 SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING"
const val PLAY_COMMAND = "play "
/** /**
* MediaBrowserService implementation for e.g. Android Auto * MediaBrowserService implementation for e.g. Android Auto
*/ */
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember") @Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
@SuppressLint("UnsafeOptInUsageError")
class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) : class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) :
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent { MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
private val mediaPlayerController by inject<MediaPlayerController>() private val mediaPlayerController by inject<MediaPlayerController>()
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
private val serviceJob = Job() private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private val mainScope = CoroutineScope(Dispatchers.Main) private val mainScope = CoroutineScope(Dispatchers.Main)
@ -152,13 +156,15 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
params: MediaLibraryService.LibraryParams? params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> { ): ListenableFuture<LibraryResult<MediaItem>> {
Timber.i("onGetLibraryRoot")
return Futures.immediateFuture( return Futures.immediateFuture(
LibraryResult.ofItem( LibraryResult.ofItem(
buildMediaItem( buildMediaItem(
"Root Folder", "Root Folder",
MEDIA_ROOT_ID, MEDIA_ROOT_ID,
isPlayable = false, isPlayable = false,
folderType = FOLDER_TYPE_MIXED folderType = FOLDER_TYPE_MIXED,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
), ),
params params
) )
@ -169,6 +175,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
session: MediaSession, session: MediaSession,
controller: MediaSession.ControllerInfo controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult { ): MediaSession.ConnectionResult {
Timber.i("onConnect")
val connectionResult = super.onConnect(session, controller) val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
@ -189,14 +196,23 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
mediaId: String mediaId: String
): ListenableFuture<LibraryResult<MediaItem>> { ): ListenableFuture<LibraryResult<MediaItem>> {
playFromMediaId(mediaId) Timber.i("onGetItem")
val tracks = tracksFromMediaId(mediaId)
val mediaItem = tracks?.firstOrNull()?.toMediaItem()
// TODO: // TODO:
// 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.
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( override fun onGetChildren(
@ -207,7 +223,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
pageSize: Int, pageSize: Int,
params: MediaLibraryService.LibraryParams? params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
// TODO: params??? Timber.i("onLoadChildren")
return onLoadChildren(parentId) return onLoadChildren(parentId)
} }
@ -217,7 +233,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
customCommand: SessionCommand, customCommand: SessionCommand,
args: Bundle args: Bundle
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
Timber.i("onCustomCommand")
var customCommandFuture: ListenableFuture<SessionResult>? = null var customCommandFuture: ListenableFuture<SessionResult>? = null
when (customCommand.customAction) { when (customCommand.customAction) {
@ -313,63 +329,80 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
* 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
*/ */
@Suppress("MagicNumber", "ComplexMethod")
override fun onAddMediaItems( override fun onAddMediaItems(
mediaSession: MediaSession, mediaSession: MediaSession,
controller: MediaSession.ControllerInfo, controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem> 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.isEmpty()) return Futures.immediateFuture(mediaItems)
if (mediaItems.firstOrNull()?.requestMetadata?.mediaUri != null) { // Return early if its a search
val updatedMediaItems = mediaItems.map { mediaItem -> if (mediaItems[0].requestMetadata.searchQuery != null)
mediaItem.buildUpon() return playFromSearch(mediaItems[0].requestMetadata.searchQuery!!)
.setUri(mediaItem.requestMetadata.mediaUri)
.build() 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 { } else {
// Android Auto devices still only use the MediaId to identify the selected Items // Android Auto devices still only use the MediaId to identify the selected Items
// They also only select a single item at once // They also only select a single item at once
val mediaIdParts = mediaItems.first().mediaId.split('|') onAddLegacyAutoItems(mediaItems)
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("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") @Suppress("ReturnCount", "ComplexMethod")
fun onLoadChildren( fun onLoadChildren(
parentId: String, parentId: String,
@ -409,26 +442,27 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
} }
} }
fun onSearch( private fun playFromSearch(
query: String, query: String,
extras: Bundle?, ): ListenableFuture<List<MediaItem>> {
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { Timber.i("AutoMediaBrowserService onSearch query: %s", query)
Timber.d("AutoMediaBrowserService onSearch query: %s", query)
val mediaItems: MutableList<MediaItem> = ArrayList() 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 { 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) } val searchResult = callWithErrorHandling { musicService.search(criteria) }
// TODO Add More... button to categories // TODO Add More... button to categories
if (searchResult != null) { 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 -> searchResult.albums.map { album ->
mediaItems.add( 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 searchSongsCache = searchResult.songs
searchResult.songs.map { song -> searchResult.songs.map { song ->
mediaItems.add( 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") @Suppress("MagicNumber", "ComplexMethod")
private fun playFromMediaId(mediaId: String?) { private fun tracksFromMediaId(mediaId: String?): List<Track>? {
Timber.d( Timber.d(
"AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s", "AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s",
mediaId mediaId
) )
if (mediaId == null) return if (mediaId == null) return null
val mediaIdParts = mediaId.split('|') 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_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2])
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong( MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
@ -483,6 +535,9 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
mediaIdParts[1], mediaIdParts[2] mediaIdParts[1], mediaIdParts[2]
) )
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) 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, R.string.music_library_label,
MEDIA_LIBRARY_ID, MEDIA_LIBRARY_ID,
null, null,
folderType = FOLDER_TYPE_MIXED,
icon = R.drawable.ic_library icon = R.drawable.ic_library
) )
@ -849,13 +905,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
return null 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) val songs = listSongsInMusicService(id, name)
if (songs != null) return songs.getTracks() if (songs != null) return songs.getTracks()
return null 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 songs = listSongsInMusicService(id, name)
val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId } val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId }
if (song != null) return listOf(song) if (song != null) return listOf(song)
@ -1132,7 +1188,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
return null return null
} }
private fun listSongsInMusicService(id: String, name: String): MusicDirectory? { private fun listSongsInMusicService(id: String, name: String?): MusicDirectory? {
return serviceScope.future { return serviceScope.future {
if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) { if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) {
callWithErrorHandling { musicService.getAlbumAsDir(id, name, false) } callWithErrorHandling { musicService.getAlbumAsDir(id, name, false) }

View File

@ -8,8 +8,13 @@ package org.moire.ultrasonic.playback
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Intent import android.content.Intent
import android.os.Build 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.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.C.USAGE_MEDIA import androidx.media3.common.C.USAGE_MEDIA
@ -29,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.activity.NavigationActivity import org.moire.ultrasonic.activity.NavigationActivity
import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.audiofx.EqualizerController
@ -66,6 +72,7 @@ class PlaybackService :
Timber.i("onCreate called") Timber.i("onCreate called")
super.onCreate() super.onCreate()
initializeSessionAndPlayer() initializeSessionAndPlayer()
setListener(MediaSessionServiceListener())
} }
private fun getWakeModeFlag(): Int { private fun getWakeModeFlag(): Int {
@ -211,10 +218,10 @@ class PlaybackService :
private fun getPendingIntentForContent(): PendingIntent { private fun getPendingIntentForContent(): PendingIntent {
val intent = Intent(this, NavigationActivity::class.java) val intent = Intent(this, NavigationActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) .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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// needed starting Android 12 (S = 31) // needed starting Android 12 (S = 31)
flags = flags or PendingIntent.FLAG_IMMUTABLE flags = flags or FLAG_IMMUTABLE
} }
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
return PendingIntent.getActivity(this, 0, intent, flags) return PendingIntent.getActivity(this, 0, intent, flags)
@ -236,4 +243,56 @@ class PlaybackService :
val context = UApp.applicationContext() val context = UApp.applicationContext()
UltrasonicAppWidgetProvider.notifyPlayerStateChange(context, isPlaying) 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
}
} }

View File

@ -17,6 +17,7 @@ import java.io.File
import java.util.Locale import java.util.Locale
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.imageloader.ImageLoader import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
@ -29,9 +30,10 @@ class AlbumArtContentProvider : ContentProvider(), KoinComponent {
companion object { companion object {
fun mapArtworkToContentProviderUri(track: Track?): Uri? { fun mapArtworkToContentProviderUri(track: Track?): Uri? {
if (track?.coverArt.isNullOrBlank()) return null if (track?.coverArt.isNullOrBlank()) return null
val domain = UApp.applicationContext().packageName + ".provider.AlbumArtContentProvider"
return Uri.Builder() return Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT) .scheme(ContentResolver.SCHEME_CONTENT)
.authority("org.moire.ultrasonic.provider.AlbumArtContentProvider") .authority(domain)
// currently only large files are cached // currently only large files are cached
.path( .path(
String.format( String.format(

View File

@ -8,8 +8,6 @@
package org.moire.ultrasonic.service package org.moire.ultrasonic.service
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
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
@ -82,8 +80,15 @@ class DownloadService : Service(), KoinComponent {
val supervisor = SupervisorJob() val supervisor = SupervisorJob()
scope = CoroutineScope(Dispatchers.IO + supervisor) scope = CoroutineScope(Dispatchers.IO + supervisor)
val notificationManagerCompat = NotificationManagerCompat.from(this)
// Create Notification Channel // Create Notification Channel
createNotificationChannel() Util.ensureNotificationChannel(
id = NOTIFICATION_CHANNEL_ID,
name = NOTIFICATION_CHANNEL_NAME,
importance = 2,
notificationManager = notificationManagerCompat
)
updateNotification() updateNotification()
if (wifiLock == null) { if (wifiLock == null) {
@ -213,25 +218,6 @@ class DownloadService : Service(), KoinComponent {
updateLiveData() 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 // We should use a single notification builder, otherwise the notification may not be updated
// Set some values that never change // Set some values that never change
private val notificationBuilder: NotificationCompat.Builder by lazy { private val notificationBuilder: NotificationCompat.Builder by lazy {
@ -252,13 +238,8 @@ class DownloadService : Service(), KoinComponent {
val notification = buildForegroundNotification() val notification = buildForegroundNotification()
if (isInForeground) { if (isInForeground) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val manager = NotificationManagerCompat.from(this)
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager Util.postNotificationIfPermitted(manager, NOTIFICATION_ID, notification)
manager.notify(NOTIFICATION_ID, notification)
} else {
val manager = NotificationManagerCompat.from(this)
manager.notify(NOTIFICATION_ID, notification)
}
Timber.v("Updated notification") Timber.v("Updated notification")
} else { } else {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)

View File

@ -17,6 +17,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO 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.common.Timeline
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionResult import androidx.media3.session.SessionResult
@ -146,6 +147,20 @@ class MediaPlayerController(
} }
isJukeboxEnabled = false 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 private var cachedMediaItem: MediaItem? = null
@ -389,12 +404,18 @@ class MediaPlayerController(
} }
if (shuffle) isShufflePlayEnabled = true if (shuffle) isShufflePlayEnabled = true
controller?.addMediaItems(insertAt, mediaItems) controller?.addMediaItems(insertAt, mediaItems)
prepare() 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 @Synchronized

View File

@ -7,12 +7,14 @@
package org.moire.ultrasonic.util package org.moire.ultrasonic.util
import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.core.net.toUri import androidx.core.net.toUri
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
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE
import androidx.media3.common.StarRating import androidx.media3.common.StarRating
import java.text.DateFormat import java.text.DateFormat
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
@ -204,19 +206,21 @@ fun MediaItem.shouldBePinned(): Boolean {
* Build a new MediaItem from a list of attributes. * Build a new MediaItem from a list of attributes.
* Especially useful to create folder entries in the Auto interface. * Especially useful to create folder entries in the Auto interface.
*/ */
@SuppressLint("UnsafeOptInUsageError")
@Suppress("LongParameterList") @Suppress("LongParameterList")
fun buildMediaItem( fun buildMediaItem(
title: String, title: String,
mediaId: String, mediaId: String,
isPlayable: Boolean, isPlayable: Boolean,
@MediaMetadata.FolderType folderType: Int, folderType: @MediaMetadata.FolderType Int,
album: String? = null, album: String? = null,
artist: String? = null, artist: String? = null,
genre: String? = null, genre: String? = null,
sourceUri: Uri? = null, sourceUri: Uri? = null,
imageUri: Uri? = null, imageUri: Uri? = null,
starred: Boolean = false, starred: Boolean = false,
group: String? = null group: String? = null,
mediaType: @MediaMetadata.MediaType Int? = null,
): MediaItem { ): MediaItem {
val metadataBuilder = MediaMetadata.Builder() val metadataBuilder = MediaMetadata.Builder()
@ -234,6 +238,14 @@ fun buildMediaItem(
metadataBuilder.setArtworkUri(imageUri) metadataBuilder.setArtworkUri(imageUri)
} }
if (folderType > FOLDER_TYPE_NONE) {
metadataBuilder.setIsBrowsable(true)
}
if (mediaType != null) {
metadataBuilder.setMediaType(mediaType)
}
if (group != null) { if (group != null) {
metadataBuilder.setExtras( metadataBuilder.setExtras(
Bundle().apply { Bundle().apply {

View File

@ -10,6 +10,9 @@ package org.moire.ultrasonic.util
import android.Manifest.permission.POST_NOTIFICATIONS import android.Manifest.permission.POST_NOTIFICATIONS
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.app.Service.STOP_FOREGROUND_REMOVE import android.app.Service.STOP_FOREGROUND_REMOVE
@ -34,6 +37,7 @@ import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem 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( if (ContextCompat.checkSelfPermission(
applicationContext(), applicationContext(),
POST_NOTIFICATIONS, POST_NOTIFICATIONS,
@ -527,9 +557,27 @@ object Util {
} }
requestPermissionLauncher.launch(POST_NOTIFICATIONS) 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 @JvmStatic
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
fun getVersionName(context: Context): String? { fun getVersionName(context: Context): String? {

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Loading…</string> <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.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> <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.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_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="server_menu.demo">Demo Server</string>
<string name="about.webpage">Visit webpage</string> <string name="about.webpage">Visit webpage</string>
<string name="about.report">Report a bug</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.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.webpage.url" translatable="false">https://ultrasonic.gitlab.io/</string>
<string name="about.report.url" translatable="false">https://gitlab.com/ultrasonic/ultrasonic/issues</string> <string name="about.report.url" translatable="false">https://gitlab.com/ultrasonic/ultrasonic/issues</string>
<plurals name="select_album_n_songs"> <plurals name="select_album_n_songs">
<item quantity="one">%d song</item> <item quantity="one">%d song</item>
<item quantity="other">%d songs</item> <item quantity="other">%d songs</item>
@ -427,6 +430,7 @@
<item quantity="one">%d song inserted after current song</item> <item quantity="one">%d song inserted after current song</item>
<item quantity="other">%d songs inserted after current song</item> <item quantity="other">%d songs inserted after current song</item>
</plurals> </plurals>
<!-- Subsonic api errors --> <!-- Subsonic api errors -->
<string name="api.subsonic.generic">Generic API error: %1$s</string> <string name="api.subsonic.generic">Generic API error: %1$s</string>
<string name="api.subsonic.generic.no.message">no message given from server</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.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_client">Incompatible versions. Please upgrade Ultrasonic Android app.</string>
<string name="api.subsonic.upgrade_server">Incompatible versions. Please upgrade Subsonic server.</string> <string name="api.subsonic.upgrade_server">Incompatible versions. Please upgrade Subsonic server.</string>
<!-- Subsonic features --> <!-- Subsonic features -->
<string name="settings.five_star_rating_title">Use five star rating for songs</string> <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> <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="grid_view">Cover</string>
<string name="supported_server_features">Supported features</string> <string name="supported_server_features">Supported features</string>
<string name="jukebox">Jukebox</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>