Add android auto support (#1972)

This commit is contained in:
Niels van Velzen
2026-05-14 21:31:51 +02:00
committed by GitHub
parent cee95e55f7
commit 3e23c631d1
24 changed files with 1049 additions and 5 deletions
+17 -4
View File
@@ -78,16 +78,26 @@
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<service android:name="org.jellyfin.mobile.downloads.JellyfinDownloadService"
<service
android:name="org.jellyfin.mobile.downloads.JellyfinDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync">
<!-- This is needed for Scheduler -->
<intent-filter>
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART"/>
<category android:name="android.intent.category.DEFAULT"/>
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<service
android:name=".sessionbrowser.LibraryService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
@@ -112,7 +122,10 @@
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@mipmap/ic_launcher_round" />
android:resource="@drawable/ic_launcher_foreground" />
<meta-data
android:name="androidx.car.app.TintableAttributionIcon"
android:resource="@drawable/ic_launcher_foreground" />
<meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"
android:value="false" />
@@ -0,0 +1,11 @@
package org.jellyfin.mobile.sessionbrowser
import org.jellyfin.sdk.model.api.BaseItemDto
sealed interface LibraryItemAction {
data class Play(
val item: BaseItemDto,
) : LibraryItemAction
data class Navigate(val route: LibraryRoute) : LibraryItemAction
}
@@ -0,0 +1,24 @@
package org.jellyfin.mobile.sessionbrowser
import kotlin.reflect.KClass
interface LibraryPage<R : LibraryRoute> {
val route: KClass<R>
val grid: Boolean
suspend fun getContent(
route: R,
offset: Int,
limit: Int,
): List<LibraryPageElement>
}
inline fun <reified T : LibraryRoute> libraryPage(
grid: Boolean = false,
crossinline getContent: suspend (route: T, offset: Int, limit: Int) -> List<LibraryPageElement>,
) = object : LibraryPage<T> {
override val route = T::class
override val grid = grid
override suspend fun getContent(route: T, offset: Int, limit: Int): List<LibraryPageElement> =
getContent(route, offset, limit)
}
@@ -0,0 +1,67 @@
package org.jellyfin.mobile.sessionbrowser
import android.net.Uri
import androidx.core.net.toUri
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
sealed interface LibraryPageElement {
/**
* A group of items displayed as a section within Android Auto.
*/
data class Group(
val title: String,
val items: List<Item>,
) : LibraryPageElement
/**
* An item with custom metadata.
*/
data class Item(
val title: String,
val artist: String? = null,
val album: String? = null,
val image: Uri? = null,
val action: LibraryItemAction,
) : LibraryPageElement
companion object {
fun baseItem(
api: ApiClient,
item: BaseItemDto,
title: String = item.name.orEmpty(),
artist: String? = item.artists?.joinToString(),
album: String? = item.album,
image: Uri? = item.getImage(api),
action: LibraryItemAction = LibraryItemAction.Play(item),
): Item = Item(
title = title,
artist = artist,
album = album,
image = image,
action = action,
)
private fun BaseItemDto.getImage(api: ApiClient): Uri? {
val primaryImageTag = imageTags?.get(ImageType.PRIMARY)
return when {
primaryImageTag != null -> api.imageApi.getItemImageUrl(
itemId = id,
imageType = ImageType.PRIMARY,
tag = primaryImageTag,
).toUri()
albumId != null && albumPrimaryImageTag != null -> api.imageApi.getItemImageUrl(
itemId = requireNotNull(albumId),
imageType = ImageType.PRIMARY,
tag = albumPrimaryImageTag,
).toUri()
else -> null
}
}
}
}
@@ -0,0 +1,53 @@
@file:UseSerializers(UUIDSerializer::class)
package org.jellyfin.mobile.sessionbrowser
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
sealed interface LibraryRoute {
@Serializable
data object Root : LibraryRoute
@Serializable
data object Suggested : LibraryRoute
@Serializable
data class Recent(val libraryId: UUID? = null) : LibraryRoute
@Serializable
data class Search(val query: String? = null) : LibraryRoute
@Serializable
data class Library(val libraryId: UUID) : LibraryRoute
@Serializable
data class Albums(val libraryId: UUID, val startLetter: String? = null) : LibraryRoute
@Serializable
data class Album(val albumId: UUID) : LibraryRoute
@Serializable
data class Artists(val libraryId: UUID, val startLetter: String? = null) : LibraryRoute
@Serializable
data class Artist(val artistId: UUID, val startLetter: String? = null) : LibraryRoute
@Serializable
data class Favorites(val libraryId: UUID) : LibraryRoute
@Serializable
data class Genres(val libraryId: UUID, val startLetter: String? = null) : LibraryRoute
@Serializable
data class Genre(val genreId: UUID, val startLetter: String? = null) : LibraryRoute
@Serializable
data class Playlists(val libraryId: UUID) : LibraryRoute
@Serializable
data class Playlist(val playlistId: UUID) : LibraryRoute
}
@@ -0,0 +1,56 @@
package org.jellyfin.mobile.sessionbrowser
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import kotlinx.coroutines.runBlocking
import org.jellyfin.mobile.app.ApiClientController
import org.jellyfin.sdk.api.client.ApiClient
import org.koin.android.ext.android.inject
class LibraryService : MediaLibraryService() {
private val apiClientController: ApiClientController by inject()
private val apiClient: ApiClient by inject()
private var mediaLibrarySession: MediaLibrarySession? = null
private var callback: MediaLibrarySession.Callback? = null
private val playerAudioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
private val exoPlayer: Player by lazy {
ExoPlayer.Builder(this).build().apply {
setAudioAttributes(playerAudioAttributes, true)
setHandleAudioBecomingNoisy(true)
}
}
override fun onGetSession(
controllerInfo: MediaSession.ControllerInfo,
): MediaLibrarySession? = mediaLibrarySession
override fun onCreate() {
super.onCreate()
runBlocking { apiClientController.loadSavedServerUser() }
if (callback == null) callback = SessionBrowserCallback(this, apiClient)
mediaLibrarySession = MediaLibrarySession.Builder(this, exoPlayer, callback!!)
.build()
}
override fun onDestroy() {
mediaLibrarySession?.run {
player.release()
release()
mediaLibrarySession = null
}
super.onDestroy()
}
}
@@ -0,0 +1,294 @@
package org.jellyfin.mobile.sessionbrowser
import android.content.Context
import android.os.Bundle
import androidx.core.os.bundleOf
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionError
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.future
import kotlinx.serialization.json.Json
import org.jellyfin.mobile.R
import org.jellyfin.mobile.sessionbrowser.page.AlbumLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.AlbumsLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.ArtistLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.ArtistsLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.FavoritesLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.GenreLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.GenresLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.PlaylistLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.PlaylistsLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.RecentLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.RootLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.SearchLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.SuggestedLibraryPage
import org.jellyfin.mobile.sessionbrowser.page.UserViewLibraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
@Suppress("InjectDispatcher")
class SessionBrowserCallback(
private val context: Context,
private val api: ApiClient,
) : MediaLibrarySession.Callback {
companion object {
const val MAX_PAGE_SIZE = 250
}
val pages = listOf(
RootLibraryPage(api),
UserViewLibraryPage(context),
AlbumsLibraryPage(api),
AlbumLibraryPage(api),
ArtistsLibraryPage(api),
ArtistLibraryPage(api),
FavoritesLibraryPage(api),
GenresLibraryPage(api),
GenreLibraryPage(api),
PlaylistsLibraryPage(api),
PlaylistLibraryPage(api),
RecentLibraryPage(api),
SuggestedLibraryPage(api),
SearchLibraryPage(api),
)
private val LibraryRoute.page get() = pages.firstOrNull { page -> page.route == this::class }
private fun LibraryPageElement.Item.toMediaItem(
groupTitle: String? = null,
): MediaItem = MediaItem.Builder().apply {
val extras = bundleOf()
groupTitle?.let {
extras.putString(androidx.media.utils.MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it)
}
if (action is LibraryItemAction.Navigate) {
val page = action.route.page
val contentStyle = when (page?.grid == true) {
true -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
false -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
}
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, contentStyle)
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, contentStyle)
setMediaId(Json.encodeToString(action.route))
} else if (action is LibraryItemAction.Play) {
setMediaId(action.item.id.toString())
}
setMediaMetadata(
MediaMetadata.Builder().apply {
setTitle(title)
setArtist(artist)
setAlbumTitle(album)
setIsBrowsable(action is LibraryItemAction.Navigate)
setIsPlayable(action is LibraryItemAction.Play)
if (image != null) setArtworkUri(image)
setExtras(extras)
}.build(),
)
}.build()
private fun List<LibraryPageElement>.toMediaItems(): List<MediaItem> = flatMap { element ->
when (element) {
is LibraryPageElement.Group -> element.items.map { item -> item.toMediaItem(groupTitle = element.title) }
is LibraryPageElement.Item -> listOf(element.toMediaItem())
}
}
private fun createPageResult(
route: LibraryRoute,
params: LibraryParams? = null,
): LibraryResult<MediaItem> {
val page = route.page
return if (page == null) {
LibraryResult.ofError(
SessionError(SessionError.ERROR_NOT_SUPPORTED, context.getString(R.string.media_service_unknown_page)),
params ?: LibraryParams.Builder().build(),
)
} else {
LibraryResult.ofItem(
MediaItem.Builder().apply {
val extras = Bundle()
val contentStyle = when (page.grid) {
true -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
false -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
}
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, contentStyle)
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, contentStyle)
setMediaId(Json.encodeToString(route))
setMediaMetadata(
MediaMetadata.Builder().apply {
setIsBrowsable(true)
setIsPlayable(false)
setExtras(extras)
}.build(),
)
}.build(),
params,
)
}
}
private suspend fun createPageContentResult(
route: LibraryRoute,
params: LibraryParams? = null,
pageIndex: Int,
pageSize: Int,
): LibraryResult<ImmutableList<MediaItem>> {
val page = route.page ?: return LibraryResult.ofError(SessionError(SessionError.ERROR_BAD_VALUE, context.getString(R.string.media_service_unknown_page)))
if (api.baseUrl == null || api.accessToken == null) {
return LibraryResult.ofError(
SessionError(
SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED,
context.getString(R.string.media_service_auth_expired),
),
)
}
@Suppress("UNCHECKED_CAST")
val items = (page as? LibraryPage<LibraryRoute>)?.getContent(
route,
pageIndex * pageSize,
minOf(pageSize, MAX_PAGE_SIZE),
)?.toMediaItems()
return if (items == null) {
LibraryResult.ofError(
SessionError(SessionError.ERROR_BAD_VALUE, context.getString(R.string.media_service_no_items)),
)
} else {
LibraryResult.ofItemList(items, params)
}
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> = CoroutineScope(Dispatchers.IO).future {
val route = when {
params?.isRecent == true -> LibraryRoute.Recent()
params?.isSuggested == true -> LibraryRoute.Suggested
else -> LibraryRoute.Root
}
Timber.d("onGetLibraryRoot $session $browser $params $route")
createPageResult(route, params)
}
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = CoroutineScope(Dispatchers.IO).future {
val route = runCatching { Json.decodeFromString<LibraryRoute>(parentId) }.getOrNull()
Timber.d("onGetChildren $parentId $page $pageSize $params")
if (route == null) {
LibraryResult.ofError(
SessionError(SessionError.ERROR_BAD_VALUE, context.getString(R.string.media_service_unknown_page)),
)
} else {
createPageContentResult(route, params, page, pageSize)
}
}
override fun onSearch(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: LibraryParams?,
): ListenableFuture<LibraryResult<Void>> = CoroutineScope(Dispatchers.IO).future {
Timber.d("onSearch $query $params")
// Add fake item count because we have not actually searched yet
session.notifySearchResultChanged(browser, query, 1, params)
LibraryResult.ofVoid(params)
}
override fun onGetSearchResult(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
page: Int,
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = CoroutineScope(Dispatchers.IO).future {
Timber.d("onGetSearchResult $query $page $pageSize $params")
createPageContentResult(LibraryRoute.Search(query), params, page, pageSize)
}
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String,
): ListenableFuture<LibraryResult<MediaItem>> = CoroutineScope(Dispatchers.IO).future {
Timber.d("onGetItem $session $browser $mediaId")
val itemId = mediaId.toUUIDOrNull()
if (itemId == null) {
LibraryResult.ofError(
SessionError(SessionError.ERROR_BAD_VALUE, context.getString(R.string.media_service_invalid_id)),
)
} else {
val item by api.userLibraryApi.getItem(itemId = mediaId.toUUID())
LibraryResult.ofItem(LibraryPageElement.baseItem(api, item).toMediaItem(), null)
}
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>,
): ListenableFuture<List<MediaItem>> = CoroutineScope(Dispatchers.IO).future {
Timber.d("onAddMediaItems $mediaSession $controller $mediaItems")
mediaItems.map {
val playbackUri = api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = it.mediaId.toUUID(),
deviceId = api.deviceInfo.id,
maxStreamingBitrate = 140000000,
container = listOf(
"opus",
"mp3|mp3",
"aac",
"m4a",
"m4b|aac",
"flac",
"webma",
"webm",
"wav",
"ogg",
),
transcodingProtocol = MediaStreamProtocol.HLS,
transcodingContainer = "ts",
audioCodec = "aac",
enableRemoteMedia = true,
)
it.buildUpon().setUri(playbackUri + "&ApiKey=${api.accessToken}").build()
}
}
}
@@ -0,0 +1,23 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
val AlbumLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Album> { route, offset, limit ->
val result by api.itemsApi.getItems(
parentId = route.albumId,
sortBy = listOf(ItemSortBy.SORT_NAME),
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
startIndex = offset,
limit = limit,
)
result.items.map { LibraryPageElement.baseItem(api, it) }
}
}
@@ -0,0 +1,38 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
val AlbumsLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Albums>(grid = true) { route, offset, limit ->
if (route.startLetter == null) return@libraryPage createAlphaBrowser { route.copy(startLetter = it) }
val result by api.itemsApi.getItems(
parentId = route.libraryId,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
nameLessThan = if (route.startLetter == ALPHA_BROWSER_OTHER) ALPHA_BROWSER_LETTERS[0].toString() else null,
nameStartsWith = if (route.startLetter == ALPHA_BROWSER_OTHER) null else route.startLetter,
startIndex = offset,
limit = limit,
)
result.items.map {
LibraryPageElement.baseItem(
api = api,
item = it,
action = LibraryItemAction.Navigate(LibraryRoute.Album(it.id)),
)
}
}
}
@@ -0,0 +1,18 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
const val ALPHA_BROWSER_OTHER = "#"
const val ALPHA_BROWSER_LETTERS = "abcdefghijklmnopqrstuvwxyz"
fun createAlphaBrowser(
createRoute: (letter: String) -> LibraryRoute,
) = "$ALPHA_BROWSER_OTHER$ALPHA_BROWSER_LETTERS".map { letter ->
val route = createRoute(letter.toString())
LibraryPageElement.Item(
title = letter.uppercase(),
action = LibraryItemAction.Navigate(route),
)
}
@@ -0,0 +1,38 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
val ArtistLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Artist>(grid = true) { route, offset, limit ->
if (route.startLetter == null) return@libraryPage createAlphaBrowser { route.copy(startLetter = it) }
val result by api.itemsApi.getItems(
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
sortBy = listOf(ItemSortBy.SORT_NAME),
artistIds = listOf(route.artistId),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
nameLessThan = if (route.startLetter == ALPHA_BROWSER_OTHER) ALPHA_BROWSER_LETTERS[0].toString() else null,
nameStartsWith = if (route.startLetter == ALPHA_BROWSER_OTHER) null else route.startLetter,
startIndex = offset,
limit = limit,
)
result.items.map {
LibraryPageElement.baseItem(
api = api,
item = it,
action = LibraryItemAction.Navigate(LibraryRoute.Album(it.id)),
)
}
}
}
@@ -0,0 +1,38 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
val ArtistsLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Artists>(grid = true) { route, offset, limit ->
if (route.startLetter == null) return@libraryPage createAlphaBrowser { route.copy(startLetter = it) }
val result by api.itemsApi.getItems(
parentId = route.libraryId,
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
nameLessThan = if (route.startLetter == ALPHA_BROWSER_OTHER) ALPHA_BROWSER_LETTERS[0].toString() else null,
nameStartsWith = if (route.startLetter == ALPHA_BROWSER_OTHER) null else route.startLetter,
startIndex = offset,
limit = limit,
)
result.items.map {
LibraryPageElement.baseItem(
api = api,
item = it,
action = LibraryItemAction.Navigate(LibraryRoute.Artist(it.id)),
)
}
}
}
@@ -0,0 +1,33 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
val FavoritesLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Favorites> { route, offset, limit ->
val result by api.itemsApi.getItems(
parentId = route.libraryId,
includeItemTypes = listOf(BaseItemKind.AUDIO),
isFavorite = true,
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
startIndex = offset,
limit = limit,
)
result.items.map {
LibraryPageElement.baseItem(
api = api,
item = it,
)
}
}
}
@@ -0,0 +1,38 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
val GenreLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Genre>(grid = true) { route, offset, limit ->
if (route.startLetter == null) return@libraryPage createAlphaBrowser { route.copy(startLetter = it) }
val result by api.itemsApi.getItems(
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
sortBy = listOf(ItemSortBy.SORT_NAME),
genreIds = listOf(route.genreId),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
nameLessThan = if (route.startLetter == ALPHA_BROWSER_OTHER) ALPHA_BROWSER_LETTERS[0].toString() else null,
nameStartsWith = if (route.startLetter == ALPHA_BROWSER_OTHER) null else route.startLetter,
startIndex = offset,
limit = limit,
)
result.items.map {
LibraryPageElement.baseItem(
api = api,
item = it,
action = LibraryItemAction.Navigate(LibraryRoute.Album(it.id)),
)
}
}
}
@@ -0,0 +1,35 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
val GenresLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Genres> { route, offset, limit ->
if (route.startLetter == null) return@libraryPage createAlphaBrowser { route.copy(startLetter = it) }
val result by api.genresApi.getGenres(
parentId = route.libraryId,
sortBy = listOf(ItemSortBy.SORT_NAME),
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
nameLessThan = if (route.startLetter == ALPHA_BROWSER_OTHER) ALPHA_BROWSER_LETTERS[0].toString() else null,
nameStartsWith = if (route.startLetter == ALPHA_BROWSER_OTHER) null else route.startLetter,
startIndex = offset,
limit = limit,
)
result.items.map {
LibraryPageElement.baseItem(
api = api,
item = it,
action = LibraryItemAction.Navigate(LibraryRoute.Genre(it.id)),
)
}
}
}
@@ -0,0 +1,23 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
val PlaylistLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Playlist>(grid = true) { route, offset, limit ->
val result by api.itemsApi.getItems(
parentId = route.playlistId,
sortBy = listOf(ItemSortBy.SORT_NAME),
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
startIndex = offset,
limit = limit,
)
result.items.map { LibraryPageElement.baseItem(api, it) }
}
}
@@ -0,0 +1,34 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
val PlaylistsLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Playlists>(grid = true) { route, offset, limit ->
val result by api.itemsApi.getItems(
parentId = route.libraryId,
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
startIndex = offset,
limit = limit,
)
result.items.map {
LibraryPageElement.baseItem(
api = api,
item = it,
action = LibraryItemAction.Navigate(LibraryRoute.Playlist(it.id)),
)
}
}
}
@@ -0,0 +1,36 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
val RecentLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Recent>(grid = true) { route, offset, limit ->
val result by api.itemsApi.getItems(
parentId = route.libraryId,
includeItemTypes = listOf(BaseItemKind.AUDIO),
filters = listOf(ItemFilter.IS_PLAYED),
sortBy = listOf(ItemSortBy.DATE_PLAYED),
sortOrder = listOf(SortOrder.DESCENDING),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
startIndex = offset,
limit = limit,
)
result.items.map {
LibraryPageElement.baseItem(
api = api,
item = it,
)
}
}
}
@@ -0,0 +1,32 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
/**
* Root library page that returns the available libraries (user views) for Android Auto playback.
* Note that this should ideally not exceed 4 items.
*/
val RootLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Root>(grid = true) { _, offset, limit ->
val userViews by api.userViewsApi.getUserViews()
userViews
.items
.filter { it.collectionType == CollectionType.MUSIC }
.drop(offset)
.take(limit)
.map {
LibraryPageElement.baseItem(
api = api,
item = it,
image = null,
action = LibraryItemAction.Navigate(LibraryRoute.Library(it.id)),
)
}
}
}
@@ -0,0 +1,57 @@
package org.jellyfin.mobile.sessionbrowser.page
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
private suspend fun search(
api: ApiClient,
query: String,
itemTypes: Collection<BaseItemKind>,
) = withContext(Dispatchers.IO) {
async {
api.itemsApi.getItems(
searchTerm = query,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 50,
includeItemTypes = itemTypes,
recursive = true,
)
}
}
val SearchLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Search>(grid = true) { route, offset, limit ->
if (route.query.isNullOrBlank()) return@libraryPage emptyList()
val (playlists, albums, artists) = listOf(
search(api, route.query, setOf(BaseItemKind.PLAYLIST)) to { item: BaseItemDto ->
LibraryPageElement.baseItem(api, item, action = LibraryItemAction.Navigate(LibraryRoute.Playlist(item.id)))
},
search(api, route.query, setOf(BaseItemKind.MUSIC_ALBUM)) to { item: BaseItemDto ->
LibraryPageElement.baseItem(api, item, action = LibraryItemAction.Navigate(LibraryRoute.Album(item.id)))
},
search(api, route.query, setOf(BaseItemKind.MUSIC_ARTIST)) to { item: BaseItemDto ->
LibraryPageElement.baseItem(api, item, action = LibraryItemAction.Navigate(LibraryRoute.Artist(item.id)))
},
).map { (deferred, mapper) ->
deferred.await().content.items.map(mapper)
}
listOf(
LibraryPageElement.Group("Playlists", playlists),
LibraryPageElement.Group("Albums", albums),
LibraryPageElement.Group("Artists", artists),
)
}
}
@@ -0,0 +1,33 @@
package org.jellyfin.mobile.sessionbrowser.page
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
val SuggestedLibraryPage = { api: ApiClient ->
libraryPage<LibraryRoute.Suggested> { _, offset, limit ->
val result by api.itemsApi.getItems(
includeItemTypes = listOf(BaseItemKind.AUDIO),
sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
startIndex = offset,
limit = limit,
)
result.items.map {
LibraryPageElement.baseItem(
api = api,
item = it,
)
}
}
}
@@ -0,0 +1,44 @@
package org.jellyfin.mobile.sessionbrowser.page
import android.content.Context
import org.jellyfin.mobile.R
import org.jellyfin.mobile.sessionbrowser.LibraryItemAction
import org.jellyfin.mobile.sessionbrowser.LibraryPageElement
import org.jellyfin.mobile.sessionbrowser.LibraryRoute
import org.jellyfin.mobile.sessionbrowser.libraryPage
val UserViewLibraryPage = { context: Context ->
libraryPage<LibraryRoute.Library> { route, offset, limit ->
listOf(
LibraryPageElement.Item(
title = context.getString(R.string.media_service_car_section_albums),
action = LibraryItemAction.Navigate(LibraryRoute.Albums(route.libraryId)),
),
LibraryPageElement.Item(
title = context.getString(R.string.media_service_car_section_artists),
action = LibraryItemAction.Navigate(LibraryRoute.Artists(route.libraryId)),
),
LibraryPageElement.Item(
title = context.getString(R.string.media_service_car_section_favorites),
action = LibraryItemAction.Navigate(LibraryRoute.Favorites(route.libraryId)),
),
LibraryPageElement.Item(
title = context.getString(R.string.media_service_car_section_genres),
action = LibraryItemAction.Navigate(LibraryRoute.Genres(route.libraryId)),
),
LibraryPageElement.Item(
title = context.getString(R.string.media_service_car_section_playlists),
action = LibraryItemAction.Navigate(LibraryRoute.Playlists(route.libraryId)),
),
LibraryPageElement.Item(
title = context.getString(R.string.media_service_car_section_recents),
action = LibraryItemAction.Navigate(LibraryRoute.Recent(route.libraryId)),
),
).drop(offset).take(limit)
}
}
+5
View File
@@ -41,9 +41,14 @@
<string name="media_service_generic_error">An error occurred</string>
<string name="media_service_item_not_found">Media item could not be found</string>
<string name="media_service_unknown_page">Unknown page</string>
<string name="media_service_no_items">No items found</string>
<string name="media_service_auth_expired">Please open the app on your phone to sign in to your Jellyfin server.</string>
<string name="media_service_invalid_id">Invalid item ID</string>
<!-- MediaService / Car-related strings -->
<string name="media_service_car_section_recents">Recently played</string>
<string name="media_service_car_section_favorites">Favorites</string>
<string name="media_service_car_section_albums">Albums</string>
<string name="media_service_car_section_artists">Artists</string>
<string name="media_service_car_section_songs">Songs</string>
+2 -1
View File
@@ -73,6 +73,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
# KotlinX
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutines" }
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization-json" }
# Core
@@ -150,7 +151,7 @@ androidx-test-espresso = { group = "androidx.test.espresso", name = "espresso-co
detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" }
[bundles]
coroutines = ["coroutines-core", "coroutines-android"]
coroutines = ["coroutines-core", "coroutines-android", "coroutines-guava"]
koin = ["koin", "koin-compose"]
coil = ["coil", "coil-network", "coil-compose"]
androidx-lifecycle = [