WIP: Native App UI with compose

This commit is contained in:
Maxr1998
2020-09-14 18:28:16 +02:00
parent 28b718da0f
commit 4448d03eb1
60 changed files with 2478 additions and 17 deletions
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name" translatable="false">Jellyfin Debug</string> <string name="app_name" translatable="false">Jellyfin Debug</string>
<string name="app_name_short" translatable="false">Jellyfin</string>
</resources> </resources>
@@ -19,6 +19,7 @@ import org.jellyfin.mobile.player.PlayerEvent
import org.jellyfin.mobile.player.PlayerFragment import org.jellyfin.mobile.player.PlayerFragment
import org.jellyfin.mobile.player.source.MediaSourceResolver import org.jellyfin.mobile.player.source.MediaSourceResolver
import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.ImageResolver
import org.jellyfin.mobile.utils.PermissionRequestHelper import org.jellyfin.mobile.utils.PermissionRequestHelper
import org.jellyfin.mobile.viewmodel.MainViewModel import org.jellyfin.mobile.viewmodel.MainViewModel
import org.jellyfin.mobile.webapp.RemoteVolumeProvider import org.jellyfin.mobile.webapp.RemoteVolumeProvider
@@ -35,6 +36,7 @@ val applicationModule = module {
single { AppPreferences(androidApplication()) } single { AppPreferences(androidApplication()) }
single { OkHttpClient() } single { OkHttpClient() }
single { ImageLoader(androidApplication()) } single { ImageLoader(androidApplication()) }
single { ImageResolver(androidApplication(), get(), get()) }
single { PermissionRequestHelper() } single { PermissionRequestHelper() }
single { WebappFunctionChannel() } single { WebappFunctionChannel() }
single { RemoteVolumeProvider(get()) } single { RemoteVolumeProvider(get()) }
@@ -2,8 +2,10 @@ package org.jellyfin.mobile
import android.app.Application import android.app.Application
import android.webkit.WebView import android.webkit.WebView
import coil.Coil
import com.melegy.redscreenofdeath.RedScreenOfDeath import com.melegy.redscreenofdeath.RedScreenOfDeath
import org.jellyfin.mobile.api.apiModule import org.jellyfin.mobile.api.apiModule
import org.jellyfin.mobile.controller.controllerModule
import org.jellyfin.mobile.model.databaseModule import org.jellyfin.mobile.model.databaseModule
import org.jellyfin.mobile.utils.JellyTree import org.jellyfin.mobile.utils.JellyTree
import org.jellyfin.mobile.utils.isWebViewSupported import org.jellyfin.mobile.utils.isWebViewSupported
@@ -37,8 +39,14 @@ class JellyfinApplication : Application() {
modules( modules(
applicationModule, applicationModule,
apiModule, apiModule,
controllerModule,
databaseModule, databaseModule,
) )
// Set Coil ImageLoader factory
Coil.setImageLoader {
koin.get()
}
} }
} }
} }
@@ -14,12 +14,12 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.mobile.cast.Chromecast import org.jellyfin.mobile.cast.Chromecast
import org.jellyfin.mobile.cast.IChromecast import org.jellyfin.mobile.cast.IChromecast
import org.jellyfin.mobile.fragment.ConnectFragment import org.jellyfin.mobile.fragment.ConnectFragment
import org.jellyfin.mobile.fragment.WebViewFragment import org.jellyfin.mobile.fragment.WebViewFragment
import org.jellyfin.mobile.player.PlayerFragment import org.jellyfin.mobile.player.PlayerFragment
import org.jellyfin.mobile.ui.MainFragment
import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.PermissionRequestHelper import org.jellyfin.mobile.utils.PermissionRequestHelper
import org.jellyfin.mobile.utils.SmartOrientationListener import org.jellyfin.mobile.utils.SmartOrientationListener
@@ -82,20 +82,26 @@ class MainActivity : AppCompatActivity() {
} }
// Load UI // Load UI
lifecycleScope.launchWhenStarted { if (true) {
mainViewModel.serverState.collect { state -> with(supportFragmentManager) {
with(supportFragmentManager) { replaceFragment<MainFragment>()
when (state) { }
ServerState.Pending -> { } else {
// TODO add loading indicator lifecycleScope.launchWhenStarted {
} mainViewModel.serverState.collect { state ->
is ServerState.Unset -> replaceFragment<ConnectFragment>() with(supportFragmentManager) {
is ServerState.Available -> { when (state) {
val currentFragment = findFragmentById(R.id.fragment_container) ServerState.Pending -> {
if (currentFragment !is WebViewFragment || currentFragment.server != state.server) { // TODO add loading indicator
replaceFragment<WebViewFragment>(Bundle().apply { }
putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server) is ServerState.Unset -> replaceFragment<ConnectFragment>()
}) is ServerState.Available -> {
val currentFragment = findFragmentById(R.id.fragment_container)
if (currentFragment !is WebViewFragment || currentFragment.server != state.server) {
replaceFragment<WebViewFragment>(Bundle().apply {
putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server)
})
}
} }
} }
} }
@@ -13,6 +13,7 @@ import org.jellyfin.sdk.api.operations.PlayStateApi
import org.jellyfin.sdk.api.operations.PlaylistsApi import org.jellyfin.sdk.api.operations.PlaylistsApi
import org.jellyfin.sdk.api.operations.SystemApi import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.api.operations.UniversalAudioApi import org.jellyfin.sdk.api.operations.UniversalAudioApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.api.operations.UserViewsApi import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.api.operations.VideosApi import org.jellyfin.sdk.api.operations.VideosApi
import org.jellyfin.sdk.discovery.AndroidBroadcastAddressesProvider import org.jellyfin.sdk.discovery.AndroidBroadcastAddressesProvider
@@ -41,6 +42,7 @@ val apiModule = module {
single { SystemApi(get()) } single { SystemApi(get()) }
single { ItemsApi(get()) } single { ItemsApi(get()) }
single { ImageApi(get()) } single { ImageApi(get()) }
single { UserApi(get()) }
single { UserViewsApi(get()) } single { UserViewsApi(get()) }
single { ArtistsApi(get()) } single { ArtistsApi(get()) }
single { GenresApi(get()) } single { GenresApi(get()) }
@@ -79,7 +79,7 @@ class ApiController(
apiClient.deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId) apiClient.deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId)
} }
private fun resetApiClientUser() { fun resetApiClientUser() {
apiClient.userId = null apiClient.userId = null
apiClient.accessToken = null apiClient.accessToken = null
apiClient.deviceInfo = baseDeviceInfo apiClient.deviceInfo = baseDeviceInfo
@@ -0,0 +1,9 @@
package org.jellyfin.mobile.controller
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val controllerModule = module {
single { LoginController(androidContext(), get(), get(), get(), get(), get()) }
single { LibraryController(get(), get()) }
}
@@ -0,0 +1,43 @@
package org.jellyfin.mobile.controller
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jellyfin.mobile.model.dto.UserViewInfo
import org.jellyfin.mobile.model.dto.toUserViewInfo
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.UUID
class LibraryController(
private val apiClient: ApiClient,
private val userViewsApi: UserViewsApi,
) {
private val scope = CoroutineScope(Dispatchers.Default)
var userViews: List<UserViewInfo> by mutableStateOf(emptyList())
init {
scope.launch {
val response by userViewsApi.getUserViews(requireNotNull(apiClient.userId))
userViews = response.items?.run {
map(BaseItemDto::toUserViewInfo).filter { item -> item.collectionType in SUPPORTED_COLLECTION_TYPES }
} ?: emptyList()
}
}
fun getCollection(id: UUID): UserViewInfo? = userViews.find { collection -> collection.id == id }
companion object {
val SUPPORTED_COLLECTION_TYPES = setOf(
"movies",
"tvshows",
"music",
"musicvideos",
)
}
}
@@ -0,0 +1,142 @@
package org.jellyfin.mobile.controller
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.mobile.R
import org.jellyfin.mobile.model.dto.UserInfo
import org.jellyfin.mobile.model.sql.dao.UserDao
import org.jellyfin.mobile.model.state.CheckUrlState
import org.jellyfin.mobile.model.state.LoginState
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import timber.log.Timber
class LoginController(
private val context: Context,
private val jellyfin: Jellyfin,
private val apiClient: ApiClient,
private val userDao: UserDao,
private val userApi: UserApi,
private val apiController: ApiController,
) {
private val scope = CoroutineScope(Dispatchers.Main)
var loginState by mutableStateOf(LoginState.PENDING)
var userInfo by mutableStateOf<UserInfo?>(null)
init {
scope.launch {
apiController.loadSavedServerUser()
val userId = apiClient.userId
loginState = if (userId != null) {
val userDto by userApi.getUserById(userId)
userInfo = UserInfo(0, userDto)
LoginState.LOGGED_IN
} else {
LoginState.NOT_LOGGED_IN
}
}
}
suspend fun checkServerUrl(enteredUrl: String): CheckUrlState {
Timber.i("checkServerUrlAndConnection $enteredUrl")
val candidates = jellyfin.discovery.getAddressCandidates(enteredUrl)
Timber.i("Address candidates are $candidates")
// Find servers and classify them into groups.
// BAD servers are collected in case we need an error message,
// GOOD are kept if there's no GREAT one.
val badServers = mutableListOf<RecommendedServerInfo>()
val goodServers = mutableListOf<RecommendedServerInfo>()
val greatServer = jellyfin.discovery.getRecommendedServers(candidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
goodServers += recommendedServer
false
}
RecommendedServerInfoScore.OK,
RecommendedServerInfoScore.BAD -> {
badServers += recommendedServer
false
}
}
}
val server = greatServer ?: goodServers.firstOrNull()
if (server != null) {
val systemInfo = requireNotNull(server.systemInfo)
Timber.i("Found valid server at ${server.address} with rating ${server.score} and version ${systemInfo.version}")
// TODO: Set server
server.address
return CheckUrlState.Success
}
// No valid server found, log and show error message
val loggedServers = badServers.joinToString { "${it.address}/${it.systemInfo}" }
Timber.i("No valid servers found, invalid candidates were: $loggedServers")
val error = if (badServers.isNotEmpty()) {
val count = badServers.size
val (unreachableServers, incompatibleServers) = badServers.partition { result -> result.systemInfo == null }
StringBuilder(context.resources.getQuantityString(R.plurals.connection_error_prefix, count, count)).apply {
if (unreachableServers.isNotEmpty()) {
append("\n\n")
append(context.getString(R.string.connection_error_unable_to_reach_sever))
append(":\n")
append(unreachableServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" })
}
if (incompatibleServers.isNotEmpty()) {
append("\n\n")
append(context.getString(R.string.connection_error_unsupported_version_or_product))
append(":\n")
append(incompatibleServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" })
}
}.toString()
} else null
return CheckUrlState.Error(error)
}
suspend fun authenticate(username: String, password: String): Boolean {
requireNotNull(apiClient.baseUrl) { "Server address not set" }
val authResult by userApi.authenticateUserByName(username, password)
val user = authResult.user
val accessToken = authResult.accessToken
if (user != null && accessToken != null) {
apiController.setupUser(0, user.id.toString(), accessToken)
userInfo = UserInfo(0, user)
loginState = LoginState.LOGGED_IN
return true
}
return false
}
fun tryLogout() {
scope.launch { logout() }
}
suspend fun logout() {
userInfo?.let { user ->
withContext(Dispatchers.IO) {
userDao.logout(user.id)
}
}
apiController.resetApiClientUser()
loginState = LoginState.NOT_LOGGED_IN
userInfo = null
}
}
@@ -0,0 +1,38 @@
package org.jellyfin.mobile.model
enum class BaseItemKind(val serialName: String) {
AggregateFolder("AggregateFolder"),
Audio("Audio"),
AudioBook("AudioBook"),
BasePluginFolder("BasePluginFolder"),
Book("Book"),
BoxSet("BoxSet"),
Channel("Channel"),
ChannelFolderItem("ChannelFolderItem"),
CollectionFolder("CollectionFolder"),
Episode("Episode"),
Folder("Folder"),
Genre("Genre"),
ManualPlaylistsFolder("ManualPlaylistsFolder"),
Movie("Movie"),
MusicAlbum("MusicAlbum"),
MusicArtist("MusicArtist"),
MusicGenre("MusicGenre"),
MusicVideo("MusicVideo"),
Person("Person"),
Photo("Photo"),
PhotoAlbum("PhotoAlbum"),
Playlist("Playlist"),
Program("Program"),
Recording("Recording"),
Season("Season"),
Series("Series"),
Studio("Studio"),
Trailer("Trailer"),
TvChannel("TvChannel"),
TvProgram("TvProgram"),
UserRootFolder("UserRootFolder"),
UserView("UserView"),
Video("Video"),
Year("Year"),
}
@@ -0,0 +1,16 @@
package org.jellyfin.mobile.model
object CollectionType {
const val Movies = "movies"
const val TvShows = "tvshows"
const val Music = "music"
const val MusicVideos = "musicvideos"
const val Trailers = "trailers"
const val HomeVideos = "homevideos"
const val BoxSets = "boxsets"
const val Books = "books"
const val Photos = "photos"
const val LiveTv = "livetv"
const val Playlists = "playlists"
const val Folders = "folders"
}
@@ -0,0 +1,13 @@
package org.jellyfin.mobile.model.dto
import androidx.compose.runtime.Immutable
import java.util.UUID
@Immutable
data class Album(
val id: UUID,
val name: String,
val albumArtist: String,
val artists: List<String>,
val primaryImageTag: String?,
)
@@ -0,0 +1,11 @@
package org.jellyfin.mobile.model.dto
import androidx.compose.runtime.Immutable
import java.util.UUID
@Immutable
data class Artist(
val id: UUID,
val name: String,
val primaryImageTag: String?,
)
@@ -0,0 +1,11 @@
package org.jellyfin.mobile.model.dto
import androidx.compose.runtime.Immutable
import java.util.UUID
@Immutable
data class FolderInfo(
val id: UUID,
val name: String,
val primaryImageTag: String?,
)
@@ -0,0 +1,13 @@
package org.jellyfin.mobile.model.dto
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import java.util.Locale
fun BaseItemDto.toUserViewInfo() = UserViewInfo(id, name.orEmpty(), collectionType.orEmpty().lowercase(Locale.ROOT), imageTags?.get(ImageType.PRIMARY))
fun BaseItemDto.toFolderInfo() = FolderInfo(id, name.orEmpty(), imageTags?.get(ImageType.PRIMARY))
fun BaseItemDto.toAlbum() = Album(id, name.orEmpty(), albumArtist.orEmpty(), artists.orEmpty(), imageTags?.get(ImageType.PRIMARY))
fun BaseItemDto.toArtist() = Artist(id, name.orEmpty(), imageTags?.get(ImageType.PRIMARY))
fun BaseItemDto.toSong() = Song(id, name.orEmpty(), artists.orEmpty(), albumId, albumPrimaryImageTag ?: imageTags?.get(ImageType.PRIMARY))
fun BaseItemDto.toMusicVideo() = MusicVideo(id, name.orEmpty(), artists.orEmpty(), album, imageTags?.get(ImageType.PRIMARY))
@@ -0,0 +1,11 @@
package org.jellyfin.mobile.model.dto
import java.util.UUID
data class MusicVideo(
val id: UUID,
val title: String,
val artists: List<String>,
val album: String?,
val primaryImageTag: String?,
)
@@ -0,0 +1,13 @@
package org.jellyfin.mobile.model.dto
import androidx.compose.runtime.Immutable
import java.util.UUID
@Immutable
data class Song(
val id: UUID,
val title: String,
val artists: List<String>,
val album: UUID?,
val primaryImageTag: String?,
)
@@ -0,0 +1,15 @@
package org.jellyfin.mobile.model.dto
import androidx.compose.runtime.Immutable
import org.jellyfin.sdk.model.api.UserDto
import java.util.UUID
@Immutable
data class UserInfo(
val id: Long,
val userId: UUID,
val name: String,
val primaryImageTag: String?,
) {
constructor(id: Long, dto: UserDto) : this(id, dto.id, dto.name.orEmpty(), dto.primaryImageTag)
}
@@ -0,0 +1,12 @@
package org.jellyfin.mobile.model.dto
import androidx.compose.runtime.Immutable
import java.util.UUID
@Immutable
data class UserViewInfo(
val id: UUID,
val name: String,
val collectionType: String,
val primaryImageTag: String?,
)
@@ -0,0 +1,8 @@
package org.jellyfin.mobile.model.state
enum class AuthState {
UNSET,
PENDING,
SUCCESS,
FAILURE,
}
@@ -0,0 +1,8 @@
package org.jellyfin.mobile.model.state
sealed class CheckUrlState {
object Unchecked : CheckUrlState()
object Pending : CheckUrlState()
object Success : CheckUrlState()
class Error(val message: String?) : CheckUrlState()
}
@@ -0,0 +1,7 @@
package org.jellyfin.mobile.model.state
enum class LoginState {
PENDING,
NOT_LOGGED_IN,
LOGGED_IN,
}
@@ -0,0 +1,138 @@
package org.jellyfin.mobile.ui
import android.os.Bundle
import androidx.compose.animation.Crossfade
import androidx.compose.runtime.Composable
import androidx.fragment.app.add
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navArgument
import androidx.navigation.compose.rememberNavController
import org.jellyfin.mobile.R
import org.jellyfin.mobile.bridge.PlayOptions
import org.jellyfin.mobile.controller.LoginController
import org.jellyfin.mobile.model.CollectionType
import org.jellyfin.mobile.model.state.LoginState
import org.jellyfin.mobile.player.PlayerFragment
import org.jellyfin.mobile.ui.AppDestinations.ROUTE_ALBUM
import org.jellyfin.mobile.ui.AppDestinations.ROUTE_ARTIST
import org.jellyfin.mobile.ui.AppDestinations.ROUTE_HOME
import org.jellyfin.mobile.ui.AppDestinations.ROUTE_MUSIC_COLLECTION
import org.jellyfin.mobile.ui.AppDestinations.ROUTE_MUSIC_VIDEO_COLLECTION
import org.jellyfin.mobile.ui.AppDestinations.ROUTE_UUID_KEY
import org.jellyfin.mobile.ui.screen.SetupScreen
import org.jellyfin.mobile.ui.screen.home.HomeScreen
import org.jellyfin.mobile.ui.screen.library.music.MusicCollectionScreen
import org.jellyfin.mobile.ui.screen.library.musicvideo.MusicVideoCollectionScreen
import org.jellyfin.mobile.ui.utils.LocalFragmentManager
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
@Composable
fun AppContent() {
val loginController: LoginController by inject()
Crossfade(targetState = loginController.loginState) { loginState ->
when (loginState) {
LoginState.PENDING -> Unit // do nothing
LoginState.NOT_LOGGED_IN -> SetupScreen()
LoginState.LOGGED_IN -> AppRouter()
}
}
}
@Composable
fun AppRouter() {
val navController = rememberNavController()
val fragmentManager = LocalFragmentManager.current
NavHost(navController = navController, startDestination = ROUTE_HOME) {
composable(ROUTE_HOME) {
HomeScreen(
onClickUserView = { userViewInfo ->
navController.navigate("${userViewInfo.collectionType}/${userViewInfo.id}")
},
)
}
composableWithUuidArgument(ROUTE_MUSIC_COLLECTION) { _, collectionId ->
MusicCollectionScreen(
collectionId = collectionId,
onGoBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
},
onClickAlbum = {
},
onClickArtist = {
},
)
}
composableWithUuidArgument(ROUTE_MUSIC_VIDEO_COLLECTION) { _, collectionId ->
MusicVideoCollectionScreen(
collectionId = collectionId,
onGoBack = {
navController.popBackStack()
},
onClickFolder = { folderId ->
navController.navigate("$ROUTE_MUSIC_VIDEO_COLLECTION/$folderId")
},
onClickMusicVideo = { musicVideoId ->
fragmentManager.beginTransaction().apply {
val playOptions = PlayOptions(
mediaSourceId = musicVideoId,
ids = listOf(musicVideoId),
startIndex = 0,
startPositionTicks = null,
audioStreamIndex = null,
subtitleStreamIndex = null,
)
val args = Bundle().apply {
putParcelable(Constants.EXTRA_MEDIA_PLAY_OPTIONS, playOptions)
}
add<PlayerFragment>(R.id.fragment_container, args = args)
addToBackStack(null)
}.commit()
},
)
}
composableWithUuidArgument(ROUTE_ALBUM) { _, _ ->
//remember(route.info) { AlbumScreen(route.info) }.Content()
}
composableWithUuidArgument(ROUTE_ARTIST) { _, _ ->
//remember(route.info) { ArtistScreen(route.info) }.Content()
}
}
}
fun NavGraphBuilder.composableWithUuidArgument(
route: String,
content: @Composable (backStackEntry: NavBackStackEntry, UUID) -> Unit,
) {
composable(
route = "$route/{$ROUTE_UUID_KEY}",
arguments = listOf(
navArgument(ROUTE_UUID_KEY) { type = NavType.StringType },
),
) { backStackEntry ->
val arguments = requireNotNull(backStackEntry.arguments)
val uuid = requireNotNull(arguments.getString(ROUTE_UUID_KEY)?.toUUIDOrNull())
content(backStackEntry, uuid)
}
}
object AppDestinations {
const val ROUTE_HOME = "home"
const val ROUTE_MOVIES_COLLECTION = CollectionType.Movies
const val ROUTE_TV_SHOWS_COLLECTION = CollectionType.TvShows
const val ROUTE_MUSIC_COLLECTION = CollectionType.Music
const val ROUTE_MUSIC_VIDEO_COLLECTION = CollectionType.MusicVideos
const val ROUTE_ALBUM = "album"
const val ROUTE_ARTIST = "artist"
const val ROUTE_UUID_KEY = "uuid"
}
@@ -0,0 +1,104 @@
package org.jellyfin.mobile.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.R
val TopBarElevation = 4.dp
val DefaultCornerRounding = RoundedCornerShape(8.dp)
@Composable
fun ScreenScaffold(
modifier: Modifier = Modifier,
title: String,
titleFont: FontFamily? = null,
canGoBack: Boolean = false,
onGoBack: () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
hasElevation: Boolean = true,
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
modifier = modifier,
topBar = {
if (canGoBack) {
TopAppBar(
title = {
Text(text = title, fontFamily = titleFont)
},
navigationIcon = {
ToolbarUpButton(onClick = onGoBack)
},
actions = actions,
backgroundColor = MaterialTheme.colors.primary,
elevation = if (hasElevation) TopBarElevation else 0.dp,
)
} else {
TopAppBar(
title = {
Text(text = title, fontFamily = titleFont)
},
actions = actions,
backgroundColor = MaterialTheme.colors.primary,
elevation = if (hasElevation) TopBarElevation else 0.dp,
)
}
},
content = content,
)
}
@Composable
fun ToolbarUpButton(
onClick: () -> Unit,
) {
IconButton(
onClick = onClick,
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back_white_24dp),
contentDescription = null,
)
}
}
@Composable
inline fun CenterRow(
content: @Composable RowScope.() -> Unit
) = Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content,
)
@Composable
fun ChipletButton(
text: String,
onClick: () -> Unit
) = OutlinedButton(
onClick = onClick,
modifier = Modifier.padding(8.dp),
shape = CircleShape,
) {
Text(text = text, color = MaterialTheme.colors.onSurface)
}
@@ -0,0 +1,32 @@
package org.jellyfin.mobile.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import org.koin.core.Koin
import org.koin.core.parameter.ParametersDefinition
import org.koin.core.qualifier.Qualifier
import org.koin.mp.KoinPlatformTools
@Composable
inline fun <reified T> inject(
qualifier: Qualifier? = null,
noinline parameters: ParametersDefinition? = null
): Lazy<T> = remember {
val context = KoinPlatformTools.defaultContext().get()
context.inject(qualifier, parameters = parameters)
}
@Composable
inline fun <reified T> get(
qualifier: Qualifier? = null,
noinline parameters: ParametersDefinition? = null,
): T = remember {
val context = KoinPlatformTools.defaultContext().get()
context.get(qualifier, parameters)
}
@Composable
fun getKoin(): Koin = remember {
val context = KoinPlatformTools.defaultContext().get()
context.get()
}
@@ -0,0 +1,42 @@
package org.jellyfin.mobile.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewTreeLifecycleOwner
import org.jellyfin.mobile.databinding.FragmentMainBinding
import org.jellyfin.mobile.ui.utils.AppTheme
import org.jellyfin.mobile.ui.utils.LocalFragmentManager
import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
class MainFragment : Fragment() {
private var _viewBinding: FragmentMainBinding? = null
private val viewBinding get() = _viewBinding!!
private val composeView: ComposeView get() = viewBinding.composeView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_viewBinding = FragmentMainBinding.inflate(inflater, container, false)
return composeView.apply { applyWindowInsetsAsMargins() }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
ViewTreeLifecycleOwner.set(composeView, this)
// Apply window insets
ViewCompat.requestApplyInsets(composeView)
composeView.setContent {
AppTheme {
CompositionLocalProvider(values = arrayOf(LocalFragmentManager provides parentFragmentManager)) {
AppContent()
}
}
}
}
}
@@ -0,0 +1,298 @@
package org.jellyfin.mobile.ui.screen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.FixedScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jellyfin.mobile.R
import org.jellyfin.mobile.controller.LoginController
import org.jellyfin.mobile.model.state.AuthState
import org.jellyfin.mobile.model.state.CheckUrlState
import org.jellyfin.mobile.ui.CenterRow
import org.jellyfin.mobile.ui.get
@Composable
fun SetupScreen() {
val loginController: LoginController = get()
val serverSelectionState = remember { mutableStateOf(false) }
val serverSelected by serverSelectionState
Surface(color = MaterialTheme.colors.background) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
) {
LogoHeader()
Crossfade(targetState = serverSelected) { serverSelected ->
if (!serverSelected) {
ServerSelection(
loginController = loginController,
serverSelectionState = serverSelectionState,
)
} else {
AuthInput(loginController = loginController)
}
}
}
}
}
@Stable
@Composable
fun LogoHeader() {
CenterRow {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
modifier = Modifier
.width(72.dp)
.height(72.dp)
.padding(top = 8.dp),
contentScale = FixedScale(1.2f),
contentDescription = null,
)
Text(
text = stringResource(R.string.app_name_short),
modifier = Modifier
.padding(vertical = 56.dp)
.padding(start = 12.dp, end = 24.dp),
fontFamily = FontFamily(Font(R.font.quicksand)),
maxLines = 1,
style = MaterialTheme.typography.h3,
)
}
}
@Composable
fun ServerSelection(
loginController: LoginController,
serverSelectionState: MutableState<Boolean>
) {
val coroutineScope = rememberCoroutineScope()
val hostnameInputState = remember { mutableStateOf("") }
val checkUrlState = remember { mutableStateOf<CheckUrlState>(CheckUrlState.Unchecked) }
var hostname by hostnameInputState
var urlStateDelegate by checkUrlState
val error = urlStateDelegate as? CheckUrlState.Error
ServerSelectionStateless(
text = hostname,
errorText = error?.message,
onTextChange = { value ->
urlStateDelegate = CheckUrlState.Unchecked
hostname = value
},
loading = urlStateDelegate is CheckUrlState.Pending,
submit = {
coroutineScope.launch {
checkUrlState.value = CheckUrlState.Pending
with(loginController) {
checkServerUrl(hostnameInputState.value).let { state ->
checkUrlState.value = state
if (state is CheckUrlState.Success) {
serverSelectionState.value = true
}
}
}
}
},
)
}
@Stable
@Composable
@OptIn(ExperimentalAnimationApi::class)
private fun ServerSelectionStateless(
text: String,
errorText: String?,
onTextChange: (String) -> Unit,
loading: Boolean,
submit: () -> Unit
) {
Column {
Text(
text = stringResource(R.string.connect_to_server_title),
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5,
)
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
label = {
Text(text = stringResource(R.string.host_input_hint))
},
isError = errorText != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions {
submit()
},
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedLabelColor = MaterialTheme.colors.secondary,
focusedBorderColor = MaterialTheme.colors.secondary,
),
)
AnimatedVisibility(visible = errorText != null) {
Text(
text = errorText.orEmpty(),
modifier = Modifier.padding(bottom = 8.dp),
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
)
}
CenterRow {
TextButton(
onClick = submit,
modifier = Modifier.padding(4.dp),
enabled = !loading,
colors = ButtonDefaults.textButtonColors(MaterialTheme.colors.secondary),
) {
Text(text = stringResource(R.string.connect_button_text))
}
}
}
}
@Composable
fun AuthInput(
loginController: LoginController
) {
val coroutineScope = rememberCoroutineScope()
val authState = remember { mutableStateOf(AuthState.UNSET) }
val usernameInputState = remember { mutableStateOf("") }
val passwordInputState = remember { mutableStateOf("") }
var authStateDelegate by authState
var username by usernameInputState
var password by passwordInputState
AuthInputStateless(
username = username,
password = password,
onUsernameChange = { user ->
authStateDelegate = AuthState.UNSET
username = user
},
onPasswordChange = { pw ->
authStateDelegate = AuthState.UNSET
password = pw
},
loading = authStateDelegate == AuthState.PENDING,
error = authStateDelegate == AuthState.FAILURE,
submit = submit@{
if (username.isEmpty() || password.isEmpty())
return@submit
coroutineScope.launch {
authStateDelegate = AuthState.PENDING
val authSuccess = loginController.authenticate(username, password)
authStateDelegate = if (authSuccess) AuthState.SUCCESS else AuthState.FAILURE
}
},
)
}
@Stable
@Composable
@OptIn(ExperimentalAnimationApi::class)
fun AuthInputStateless(
username: String,
password: String,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
loading: Boolean,
error: Boolean,
submit: () -> Unit
) {
Column {
val passwordFocusRequester = remember { FocusRequester() }
Text(
text = stringResource(R.string.connect_to_server_title),
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5,
)
OutlinedTextField(
value = username,
onValueChange = onUsernameChange,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
label = {
Text(text = stringResource(R.string.username_input_hint))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions {
passwordFocusRequester.requestFocus()
},
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedLabelColor = MaterialTheme.colors.secondary,
focusedBorderColor = MaterialTheme.colors.secondary,
),
)
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
modifier = Modifier
.focusRequester(passwordFocusRequester)
.fillMaxWidth()
.padding(bottom = 8.dp),
label = {
Text(text = stringResource(R.string.password_input_hint))
},
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions {
submit()
},
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedLabelColor = MaterialTheme.colors.secondary,
focusedBorderColor = MaterialTheme.colors.secondary,
),
)
AnimatedVisibility(visible = error) {
Text(
text = stringResource(R.string.error_text_wrong_login),
modifier = Modifier.padding(bottom = 8.dp),
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
)
}
CenterRow {
TextButton(
onClick = submit,
modifier = Modifier.padding(4.dp),
enabled = !loading && username.isNotEmpty() && password.isNotEmpty(),
colors = ButtonDefaults.textButtonColors(MaterialTheme.colors.secondary),
) {
Text(text = stringResource(R.string.login_button_text))
}
}
}
}
@@ -0,0 +1,62 @@
package org.jellyfin.mobile.ui.screen.home
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.R
import org.jellyfin.mobile.controller.LibraryController
import org.jellyfin.mobile.controller.LoginController
import org.jellyfin.mobile.model.dto.UserViewInfo
import org.jellyfin.mobile.ui.ScreenScaffold
import org.jellyfin.mobile.ui.get
@Composable
fun HomeScreen(
onClickUserView: (UserViewInfo) -> Unit,
) {
val loginController: LoginController = get()
val libraryController: LibraryController = get()
val currentUser = loginController.userInfo ?: return
val userDetailsState = remember { mutableStateOf(false) }
val (userDetailsShown, showUserDetails) = userDetailsState
ScreenScaffold(
title = stringResource(R.string.app_name_short),
titleFont = FontFamily(Font(R.font.quicksand)),
actions = {
UserDetailsButton(
user = currentUser,
showUserDetails = showUserDetails,
)
},
) {
Column {
Text(
text = "Welcome, ${currentUser.name}",
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h5,
)
UserViews(
views = libraryController.userViews,
onClickView = onClickUserView,
)
}
if (userDetailsShown) {
UserDetails(
loginController = loginController,
user = currentUser,
showUserDetails = showUserDetails,
)
}
}
}
@@ -0,0 +1,110 @@
package org.jellyfin.mobile.ui.screen.home
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import org.jellyfin.mobile.R
import org.jellyfin.mobile.controller.LoginController
import org.jellyfin.mobile.model.dto.UserInfo
import org.jellyfin.mobile.ui.ChipletButton
import org.jellyfin.mobile.ui.DefaultCornerRounding
import org.jellyfin.mobile.ui.utils.ApiUserImage
import org.jellyfin.mobile.utils.toast
@Composable
fun UserImage(modifier: Modifier = Modifier, user: UserInfo) {
Surface(
modifier = Modifier
.size(56.dp)
.padding(8.dp)
.clip(CircleShape)
.then(modifier),
color = MaterialTheme.colors.primaryVariant,
) {
ApiUserImage(
userInfo = user,
modifier = Modifier.size(40.dp),
)
}
}
@Composable
fun UserDetailsButton(
modifier: Modifier = Modifier,
user: UserInfo,
showUserDetails: (Boolean) -> Unit
) {
UserImage(
modifier = modifier.clickable {
showUserDetails(true)
},
user = user,
)
}
@Composable
fun UserDetails(
loginController: LoginController,
user: UserInfo,
showUserDetails: (Boolean) -> Unit
) {
Popup(
alignment = Alignment.TopCenter,
properties = PopupProperties(focusable = true),
onDismissRequest = {
showUserDetails(false)
},
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
shape = DefaultCornerRounding,
) {
Column(modifier = Modifier
.padding(8.dp)
.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
UserImage(user = user)
Text(
text = user.name,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
val context = LocalContext.current
ChipletButton(
text = stringResource(R.string.profile_button_text),
onClick = {
context.toast("Not implemented")
},
)
ChipletButton(
text = stringResource(R.string.logout_button_text),
onClick = loginController::tryLogout,
)
}
}
}
}
}
@@ -0,0 +1,63 @@
package org.jellyfin.mobile.ui.screen.home
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.model.dto.UserViewInfo
import org.jellyfin.mobile.ui.DefaultCornerRounding
import org.jellyfin.mobile.ui.utils.ApiImage
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun UserViews(
views: List<UserViewInfo>,
onClickView: (UserViewInfo) -> Unit,
) {
LazyRow(
modifier = Modifier.fillMaxWidth(1f),
contentPadding = PaddingValues(start = 12.dp, end = 12.dp),
) {
items(views) { item ->
UserView(view = item, onClick = onClickView)
}
}
}
@Composable
fun UserView(
view: UserViewInfo,
onClick: (UserViewInfo) -> Unit,
) {
Column(modifier = Modifier.padding(4.dp)) {
val width = 256.dp
val height = 144.dp
ApiImage(
id = view.id,
modifier = Modifier
.width(width)
.height(height)
.clip(DefaultCornerRounding)
.clickable(onClick = { onClick(view) }),
imageType = ImageType.PRIMARY,
imageTag = view.primaryImageTag,
)
Text(
text = view.name,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(vertical = 8.dp),
)
}
}
@@ -0,0 +1,76 @@
package org.jellyfin.mobile.ui.screen.library
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.ui.DefaultCornerRounding
import org.jellyfin.mobile.ui.utils.ApiImage
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID
@Stable
@Composable
fun BaseMediaItem(
modifier: Modifier = Modifier,
id: UUID,
title: String,
subtitle: String? = null,
primaryImageTag: String? = null,
@DrawableRes fallbackResource: Int = 0,
imageDecorator: @Composable () -> Unit = {},
onClick: () -> Unit = {},
) {
Column(
modifier = modifier
.clip(DefaultCornerRounding)
.clickable(onClick = onClick)
.padding(8.dp),
) {
BoxWithConstraints {
val imageSize = with(LocalDensity.current) { constraints.maxWidth.toDp() }
ApiImage(
id = id,
modifier = Modifier
.size(imageSize)
.clip(DefaultCornerRounding),
imageType = ImageType.PRIMARY,
imageTag = primaryImageTag,
fallback = {
Image(
painter = painterResource(fallbackResource),
contentDescription = null,
)
},
)
imageDecorator()
}
Text(
text = title,
modifier = Modifier.padding(top = 6.dp, bottom = if (subtitle != null) 2.dp else 0.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
subtitle?.let { subtitle ->
Text(
text = subtitle,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.caption,
)
}
}
}
@@ -0,0 +1,13 @@
package org.jellyfin.mobile.ui.screen.library
import androidx.lifecycle.ViewModel
import org.jellyfin.mobile.model.dto.UserViewInfo
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.operations.ItemsApi
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
abstract class LibraryViewModel(protected val viewInfo: UserViewInfo) : ViewModel(), KoinComponent {
protected val apiClient: ApiClient by inject()
protected val itemsApi: ItemsApi by inject()
}
@@ -0,0 +1,61 @@
package org.jellyfin.mobile.ui.screen.library
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ScrollableTabRow
import androidx.compose.material.Tab
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.zIndex
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import kotlinx.coroutines.launch
import org.jellyfin.mobile.ui.TopBarElevation
@Composable
@OptIn(ExperimentalPagerApi::class)
fun TabbedContent(
tabTitles: List<String>,
currentTabState: MutableState<Int>,
pageContent: @Composable (Int) -> Unit
) {
Column {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = tabTitles.size, initialPage = 0)
val elevationPx = with(LocalDensity.current) { TopBarElevation.toPx() }
ScrollableTabRow(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer(shadowElevation = elevationPx)
.zIndex(TopBarElevation.value),
selectedTabIndex = pagerState.currentPage,
backgroundColor = MaterialTheme.colors.primary,
divider = {},
) {
tabTitles.forEachIndexed { index, title ->
Tab(selected = index == pagerState.currentPage, onClick = {
coroutineScope.launch {
currentTabState.value = index
pagerState.scrollToPage(index)
}
}, text = {
Text(title)
})
}
}
HorizontalPager(state = pagerState) { page ->
Column(modifier = Modifier.fillMaxSize()) {
pageContent(page)
}
}
}
}
@@ -0,0 +1,33 @@
package org.jellyfin.mobile.ui.screen.library.music
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.model.dto.Album
import org.jellyfin.mobile.ui.screen.library.BaseMediaItem
import org.jellyfin.mobile.ui.utils.GridListFor
@Composable
fun AlbumList(
albums: SnapshotStateList<Album>,
onClick: (Album) -> Unit = {},
) {
GridListFor(
items = albums,
numberOfColumns = 3,
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp),
) { album ->
BaseMediaItem(
modifier = Modifier.fillItemMaxWidth(),
id = album.id,
title = album.name,
subtitle = album.albumArtist,
primaryImageTag = album.primaryImageTag,
onClick = {
onClick(album)
},
)
}
}
@@ -0,0 +1,113 @@
package org.jellyfin.mobile.ui.screen.library.music
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.R
import org.jellyfin.mobile.model.dto.Album
import org.jellyfin.mobile.ui.DefaultCornerRounding
import org.jellyfin.mobile.ui.ToolbarUpButton
import org.jellyfin.mobile.ui.inject
import org.jellyfin.mobile.ui.utils.ApiImage
import org.jellyfin.mobile.utils.ImageResolver
@OptIn(ExperimentalUnsignedTypes::class)
@Composable
fun AlbumScreen(album: Album) {
val onPrimaryColor = MaterialTheme.colors.onPrimary
val backgroundColor = MaterialTheme.colors.background
var titleColor: Color by remember { mutableStateOf(onPrimaryColor) }
var gradientBackgroundColor: Color by remember { mutableStateOf(backgroundColor) }
val imageResolver: ImageResolver by inject()
LaunchedEffect(Unit) {
imageResolver.getImagePalette(album.id, album.primaryImageTag)?.dominantSwatch?.run {
titleColor = Color(titleTextColor)
gradientBackgroundColor = Color(rgb)
}
}
Surface(
color = MaterialTheme.colors.background,
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.drawBehind {
drawRect(
brush = Brush.verticalGradient(
colors = listOf(gradientBackgroundColor, backgroundColor),
startY = 0f,
endY = size.height,
)
)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
TopAppBar(
title = {
Text(text = album.name)
},
navigationIcon = {
ToolbarUpButton(onClick = {})
},
backgroundColor = Color.Transparent,
contentColor = titleColor,
elevation = 0.dp,
)
Box(
modifier = Modifier.padding(top = 56.dp, bottom = 20.dp),
) {
ApiImage(
id = album.id,
modifier = Modifier
.size(160.dp)
.clip(DefaultCornerRounding),
imageTag = album.primaryImageTag,
fallback = {
Image(painter = painterResource(R.drawable.fallback_image_album_cover), contentDescription = null)
},
)
}
Text(
text = album.name,
modifier = Modifier.padding(horizontal = 16.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h3,
)
Text(
text = album.albumArtist,
modifier = Modifier.padding(horizontal = 16.dp),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.h5,
)
}
}
}
@@ -0,0 +1,32 @@
package org.jellyfin.mobile.ui.screen.library.music
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.model.dto.Artist
import org.jellyfin.mobile.ui.screen.library.BaseMediaItem
import org.jellyfin.mobile.ui.utils.GridListFor
@Composable
fun ArtistList(
artists: SnapshotStateList<Artist>,
onClick: (Artist) -> Unit = {},
) {
GridListFor(
items = artists,
numberOfColumns = 3,
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp),
) { artist ->
BaseMediaItem(
modifier = Modifier.fillItemMaxWidth(),
id = artist.id,
title = artist.name,
primaryImageTag = artist.primaryImageTag,
onClick = {
onClick(artist)
},
)
}
}
@@ -0,0 +1,16 @@
package org.jellyfin.mobile.ui.screen.library.music
import androidx.compose.runtime.Composable
import org.jellyfin.mobile.model.dto.Artist
import org.jellyfin.mobile.ui.ScreenScaffold
@Composable
fun ArtistScreen(artist: Artist) {
ScreenScaffold(
title = artist.name,
canGoBack = true,
hasElevation = false,
) {
}
}
@@ -0,0 +1,56 @@
package org.jellyfin.mobile.ui.screen.library.music
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import org.jellyfin.mobile.R
import org.jellyfin.mobile.controller.LibraryController
import org.jellyfin.mobile.ui.ScreenScaffold
import org.jellyfin.mobile.ui.get
import org.jellyfin.mobile.ui.screen.library.TabbedContent
import java.util.UUID
@Composable
fun MusicCollectionScreen(
collectionId: UUID,
onGoBack: () -> Unit,
onClickAlbum: () -> Unit,
onClickArtist: () -> Unit,
) {
val libraryController: LibraryController = get()
val collection = remember(collectionId) { libraryController.getCollection(collectionId) }!! // TODO
ScreenScaffold(
title = collection.name,
canGoBack = true,
onGoBack = onGoBack,
hasElevation = false,
) {
val viewModel = remember { MusicViewModel(collection) }
val tabTitles = remember {
listOf(R.string.library_music_tab_albums, R.string.library_music_tab_artists, R.string.library_music_tab_songs)
}.map { id ->
stringResource(id)
}
TabbedContent(
tabTitles = tabTitles,
currentTabState = viewModel.currentTab,
) { page ->
when (page) {
0 -> AlbumList(
albums = viewModel.albums,
onClick = {
onClickAlbum()
},
)
1 -> ArtistList(
artists = viewModel.artists,
onClick = {
onClickArtist()
},
)
2 -> SongList(songs = viewModel.songs)
}
}
}
}
@@ -0,0 +1,63 @@
package org.jellyfin.mobile.ui.screen.library.music
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.jellyfin.mobile.model.BaseItemKind
import org.jellyfin.mobile.model.CollectionType
import org.jellyfin.mobile.model.dto.Album
import org.jellyfin.mobile.model.dto.Artist
import org.jellyfin.mobile.model.dto.Song
import org.jellyfin.mobile.model.dto.UserViewInfo
import org.jellyfin.mobile.model.dto.toSong
import org.jellyfin.mobile.ui.screen.library.LibraryViewModel
import org.jellyfin.sdk.model.api.ItemFields
class MusicViewModel(viewInfo: UserViewInfo) : LibraryViewModel(viewInfo) {
val currentTab = mutableStateOf(0)
val albums = mutableStateListOf<Album>()
val artists = mutableStateListOf<Artist>()
val songs = mutableStateListOf<Song>()
init {
require(viewInfo.collectionType == CollectionType.Music) {
"Invalid ViewModel for collection type ${viewInfo.collectionType}"
}
viewModelScope.launch {
/*launch {
apiClient.getItems(buildItemQuery(BaseItemType.MusicAlbum))?.run {
albums += items.map(BaseItemDto::toAlbumInfo)
}
}
launch {
apiClient.getAlbumArtists(
userId = apiClient.currentUserId,
parentId = viewInfo.id,
recursive = true,
sortBy = arrayOf(ItemSortBy.SortName),
startIndex = 0,
limit = 100,
)?.run {
artists += items.map(BaseItemDto::toArtistInfo)
}
}*/
launch {
val result by itemsApi.getItemsByUserId(
parentId = viewInfo.id,
includeItemTypes = listOf(BaseItemKind.Audio.serialName),
recursive = true,
sortBy = listOf(ItemFields.SORT_NAME.serialName),
startIndex = 0,
limit = 100,
)
songs.clear()
result.items?.forEach { item ->
songs += item.toSong()
}
}
}
}
}
@@ -0,0 +1,104 @@
package org.jellyfin.mobile.ui.screen.library.music
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.R
import org.jellyfin.mobile.model.dto.Song
import org.jellyfin.mobile.ui.DefaultCornerRounding
import org.jellyfin.mobile.ui.utils.ApiImage
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
@Composable
fun SongList(songs: SnapshotStateList<Song>) {
LazyColumn(
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp),
) {
items(items = songs) { song ->
Song(song = song, onClick = {
Timber.d("Clicked ${song.title}")
})
}
}
}
@Stable
@Composable
fun Song(
song: Song,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onClickMenu: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(72.dp)
.clickable(onClick = onClick)
.padding(start = 16.dp, end = 4.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ApiImage(
id = song.album ?: song.id,
modifier = Modifier
.size(56.dp)
.clip(DefaultCornerRounding),
imageType = ImageType.PRIMARY,
imageTag = song.primaryImageTag,
fallback = {
Image(
painter = painterResource(R.drawable.fallback_image_album_cover),
contentDescription = null,
)
},
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
) {
Text(
text = song.title,
modifier = Modifier.padding(bottom = 4.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text = song.artists.joinToString(),
modifier = Modifier.padding(bottom = 2.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.caption,
)
}
IconButton(onClick = onClickMenu) {
Icon(
painter = painterResource(R.drawable.ic_overflow_white_24dp),
contentDescription = null,
)
}
}
}
@@ -0,0 +1,146 @@
package org.jellyfin.mobile.ui.screen.library.musicvideo
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.R
import org.jellyfin.mobile.model.BaseItemKind
import org.jellyfin.mobile.model.dto.FolderInfo
import org.jellyfin.mobile.model.dto.MusicVideo
import org.jellyfin.mobile.model.dto.toFolderInfo
import org.jellyfin.mobile.model.dto.toMusicVideo
import org.jellyfin.mobile.ui.ScreenScaffold
import org.jellyfin.mobile.ui.get
import org.jellyfin.mobile.ui.screen.library.BaseMediaItem
import org.jellyfin.mobile.ui.utils.GridListFor
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
import java.util.UUID
@Composable
fun MusicVideoCollectionScreen(
collectionId: UUID,
onGoBack: () -> Unit,
onClickFolder: (UUID) -> Unit,
onClickMusicVideo: (UUID) -> Unit,
) {
val itemsApi: ItemsApi = get()
val collectionItem by produceState<BaseItemDto?>(initialValue = null) {
val result by itemsApi.getItemsByUserId(ids = listOf(collectionId))
value = result.items?.firstOrNull()
}
val items by produceState<List<Any>>(initialValue = emptyList()) {
val result by itemsApi.getItemsByUserId(
parentId = collectionId,
sortBy = listOf("IsFolder", ItemFields.SORT_NAME.serialName),
startIndex = 0,
limit = 100,
)
value = result.items?.mapNotNull { item ->
when (item.type) {
BaseItemKind.Folder.serialName -> item.toFolderInfo()
BaseItemKind.MusicVideo.serialName -> item.toMusicVideo()
else -> null
}
}.orEmpty()
}
ScreenScaffold(
title = collectionItem?.name.orEmpty(),
canGoBack = true,
onGoBack = onGoBack,
hasElevation = false,
) {
MusicVideoList(
items = items,
onClickFolder = onClickFolder,
onClickMusicVideo = onClickMusicVideo,
)
}
}
@Composable
fun MusicVideoList(
items: List<Any>,
onClickFolder: (UUID) -> Unit,
onClickMusicVideo: (UUID) -> Unit,
) {
GridListFor(items = items) { item ->
when (item) {
is FolderInfo -> FolderItem(
folderInfo = item,
modifier = Modifier.fillItemMaxWidth(),
onClick = { onClickFolder(item.id) },
)
is MusicVideo -> MusicVideoItem(
musicVideo = item,
modifier = Modifier.fillItemMaxWidth(),
onClick = { onClickMusicVideo(item.id) }
)
}
}
}
@Composable
fun FolderItem(
folderInfo: FolderInfo,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
BaseMediaItem(
modifier = modifier,
id = folderInfo.id,
title = folderInfo.name,
primaryImageTag = folderInfo.primaryImageTag,
imageDecorator = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
horizontalAlignment = Alignment.End,
) {
Icon(
painter = painterResource(R.drawable.ic_folder_white_24dp),
modifier = Modifier
.background(Color.Black.copy(alpha = 0.4f), CircleShape)
.padding(6.dp),
contentDescription = null,
)
}
},
onClick = onClick,
)
}
@Composable
fun MusicVideoItem(
musicVideo: MusicVideo,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
BaseMediaItem(
modifier = modifier,
id = musicVideo.id,
title = musicVideo.title,
subtitle = musicVideo.album,
primaryImageTag = musicVideo.primaryImageTag,
imageDecorator = {
// TODO: add watched state
},
onClick = onClick,
)
}
@@ -0,0 +1,41 @@
package org.jellyfin.mobile.ui.screen.library.musicvideo
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.jellyfin.mobile.model.BaseItemKind
import org.jellyfin.mobile.model.CollectionType
import org.jellyfin.mobile.model.dto.UserViewInfo
import org.jellyfin.mobile.model.dto.toFolderInfo
import org.jellyfin.mobile.model.dto.toMusicVideo
import org.jellyfin.mobile.ui.screen.library.LibraryViewModel
import org.jellyfin.sdk.model.api.ItemFields
class MusicVideoViewModel(viewInfo: UserViewInfo) : LibraryViewModel(viewInfo) {
val contents = mutableStateListOf<Any>()
init {
require(viewInfo.collectionType == CollectionType.MusicVideos) {
"Invalid ViewModel for collection type ${viewInfo.collectionType}"
}
viewModelScope.launch {
launch {
val result by itemsApi.getItemsByUserId(
parentId = viewInfo.id,
sortBy = listOf("IsFolder", ItemFields.SORT_NAME.serialName),
startIndex = 0,
limit = 100,
)
contents.clear()
result.items?.forEach { item ->
contents += when (item.type) {
BaseItemKind.Folder.serialName -> item.toFolderInfo()
else -> item.toMusicVideo()
}
}
}
}
}
}
@@ -0,0 +1,118 @@
@file:Suppress("NOTHING_TO_INLINE")
package org.jellyfin.mobile.ui.utils
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import com.google.accompanist.coil.rememberCoilPainter
import com.google.accompanist.imageloading.ImageLoadState
import org.jellyfin.mobile.R
import org.jellyfin.mobile.model.dto.UserInfo
import org.jellyfin.mobile.ui.inject
import org.jellyfin.sdk.api.operations.ImageApi
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID
@Stable
@Composable
fun ApiImage(
id: UUID,
modifier: Modifier = Modifier,
imageType: ImageType = ImageType.PRIMARY,
imageTag: String? = null,
fallback: @Composable (BoxScope.(ImageLoadState.Error) -> Unit)? = null
) {
val imageApi: ImageApi by inject()
BoxWithConstraints(modifier = modifier) {
val imageUrl = remember(id, constraints, imageType, imageTag) {
imageApi.getItemImageUrl(
itemId = id,
imageType = imageType,
maxWidth = constraints.maxWidth,
maxHeight = constraints.maxHeight,
quality = 90,
tag = imageTag,
)
}
Image(
modifier = Modifier.size(maxWidth, maxHeight),
painter = rememberCoilPainter(
request = imageUrl,
),
contentScale = ContentScale.Crop,
contentDescription = null,
)
/*CoilImage(
data = imageUrl,
modifier = Modifier.size(maxWidth, maxHeight),
contentScale = ContentScale.Crop,
error = fallback,
loading = { LoadingSurface(Modifier.fillMaxSize()) },
contentDescription = null,
)*/
}
}
@Stable
@Composable
fun ApiUserImage(
id: UUID,
modifier: Modifier = Modifier,
imageTag: String? = null
) {
val imageApi: ImageApi by inject()
BoxWithConstraints(modifier = modifier) {
val imageUrl = remember(id, constraints, imageTag) {
imageApi.getUserImageUrl(
userId = id,
imageType = ImageType.PRIMARY,
maxWidth = constraints.maxWidth,
maxHeight = constraints.maxHeight,
quality = 90,
tag = imageTag,
)
}
Image(
modifier = Modifier.size(maxWidth, maxHeight),
painter = rememberCoilPainter(
request = imageUrl,
previewPlaceholder = R.drawable.fallback_image_person,
),
contentScale = ContentScale.Crop,
contentDescription = null,
)
/*CoilImage(
data = imageUrl,
modifier = Modifier.size(maxWidth, maxHeight),
contentScale = ContentScale.Crop,
error = {
Image(
painter = painterResource(R.drawable.fallback_image_person),
contentDescription = null,
)
},
loading = { LoadingSurface(Modifier.fillMaxSize()) },
contentDescription = null,
)*/
}
}
@Composable
inline fun ApiUserImage(
userInfo: UserInfo,
modifier: Modifier = Modifier,
) {
ApiUserImage(
id = userInfo.userId,
modifier = modifier,
imageTag = userInfo.primaryImageTag,
)
}
@@ -0,0 +1,31 @@
package org.jellyfin.mobile.ui.utils
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import org.jellyfin.mobile.R
@Composable
fun AppTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val colors = remember {
darkColors(
primary = Color(ContextCompat.getColor(context, R.color.jellyfin_primary)),
primaryVariant = Color(ContextCompat.getColor(context, R.color.jellyfin_primary_dark)),
secondary = Color(ContextCompat.getColor(context, R.color.jellyfin_accent)),
background = Color(ContextCompat.getColor(context, R.color.theme_background)),
surface = Color(ContextCompat.getColor(context, R.color.theme_surface)),
error = Color(ContextCompat.getColor(context, R.color.error_text_color)),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
onError = Color.White,
)
}
MaterialTheme(colors = colors, content = content)
}
@@ -0,0 +1,86 @@
package org.jellyfin.mobile.ui.utils
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.max
import kotlin.math.min
interface GridScope {
fun Modifier.fillItemMaxWidth(): Modifier
}
@Composable
fun <T> GridListFor(
items: List<T>,
modifier: Modifier = Modifier,
numberOfColumns: Int = 2,
contentPadding: PaddingValues = PaddingValues(0.dp),
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
itemContent: @Composable GridScope.(T) -> Unit
) {
BoxWithConstraints {
val maxItemWidth = with(LocalDensity.current) {
constraints.maxWidth.toDp() / numberOfColumns
}
val gridScope = GridScopeImpl(maxItemWidth)
// TODO: Use LazyVerticalGrid in the future
LazyColumn(
modifier = modifier,
contentPadding = contentPadding,
horizontalAlignment = horizontalAlignment,
) {
items(GridList(items, numberOfColumns)) { row ->
Row(Modifier.fillParentMaxWidth()) {
row.forEach { info ->
gridScope.itemContent(info)
}
}
}
}
}
}
private data class GridScopeImpl(val maxWidth: Dp) : GridScope {
override fun Modifier.fillItemMaxWidth() = width(maxWidth)
}
@Stable
internal class GridList<T>(private val wrappedList: List<T>, private val numberOfColumns: Int) : List<List<T>> {
override val size: Int
get() {
val size = wrappedList.size / numberOfColumns
return if (wrappedList.size % numberOfColumns == 0) size else size + 1
}
override fun get(index: Int): List<T> {
val startIndex = index * numberOfColumns
return wrappedList.subList(startIndex, min(startIndex + numberOfColumns, max(0, wrappedList.size)))
}
override fun isEmpty(): Boolean = wrappedList.isEmpty()
override fun iterator(): Iterator<List<T>> = object : Iterator<List<T>> {
override fun hasNext(): Boolean = false
override fun next(): List<T> = throw UnsupportedOperationException()
}
override fun contains(element: List<T>) = false
override fun containsAll(elements: Collection<List<T>>): Boolean = false
override fun indexOf(element: List<T>): Int = -1
override fun lastIndexOf(element: List<T>): Int = -1
override fun listIterator(): ListIterator<List<T>> = throw UnsupportedOperationException()
override fun listIterator(index: Int): ListIterator<List<T>> = throw UnsupportedOperationException()
override fun subList(fromIndex: Int, toIndex: Int): List<List<T>> = throw UnsupportedOperationException()
}
@@ -0,0 +1,49 @@
package org.jellyfin.mobile.ui.utils
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
//private val surfaceColorProp = ColorPropKey()
private val LoadingBlinkEasing = CubicBezierEasing(0.5f, 0f, 0.8f, 0.4f)
@Stable
@Composable
fun LoadingSurface(
modifier: Modifier = Modifier,
baseColor: Color = MaterialTheme.colors.onSurface
) {
/*val state = transition(
definition = createTransition(baseColor = baseColor),
initState = 0,
toState = 1,
)*/
Surface(
modifier = modifier,
//color = state[surfaceColorProp],
content = {},
)
}
/*
@Stable
@Composable
private fun createTransition(baseColor: Color) = remember(baseColor) {
transitionDefinition<Int> {
state(0) { this[surfaceColorProp] = baseColor.copy(alpha = 0.12f) }
state(1) { this[surfaceColorProp] = baseColor.copy(alpha = 0f) }
transition {
surfaceColorProp using repeatable(
AnimationConstants.Infinite,
tween(durationMillis = 600, easing = LoadingBlinkEasing),
RepeatMode.Reverse,
)
}
}
}
*/
@@ -0,0 +1,8 @@
package org.jellyfin.mobile.ui.utils
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.fragment.app.FragmentManager
val LocalFragmentManager = staticCompositionLocalOf<FragmentManager> {
error("Missing LocalFragmentManager")
}
@@ -0,0 +1,36 @@
package org.jellyfin.mobile.ui.utils
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
/**
* Taken from
* https://github.com/android/compose-samples/blob/master/Jetcaster/app/src/main/java/com/example/jetcaster/util/ViewModel.kt
*/
/**
* Returns a [ViewModelProvider.Factory] which will return the result of [create] when it's
* [ViewModelProvider.Factory.create] function is called.
*
* If the created [ViewModel] does not match the requested class, an [IllegalArgumentException]
* exception is thrown.
*/
fun <VM : ViewModel> viewModelProviderFactoryOf(
create: () -> VM
): ViewModelProvider.Factory = SimpleFactory(create)
/**
* This needs to be a named class currently to workaround a compiler issue: b/163807311
*/
private class SimpleFactory<VM : ViewModel>(
private val create: () -> VM
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val vm = create()
if (modelClass.isInstance(vm)) {
@Suppress("UNCHECKED_CAST")
return vm as T
}
throw IllegalArgumentException("Can not create ViewModel for class: $modelClass")
}
}
@@ -0,0 +1,40 @@
package org.jellyfin.mobile.utils
import android.content.Context
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
import androidx.palette.graphics.Palette
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.operations.ImageApi
import org.jellyfin.sdk.model.api.ImageType
import java.util.*
class ImageResolver(
private val context: Context,
private val imageApi: ImageApi,
private val imageLoader: ImageLoader,
) {
suspend fun getImagePalette(
id: UUID,
imageTag: String?,
imageType: ImageType = ImageType.PRIMARY
): Palette? {
val url = imageApi.getItemImageUrl(
id,
imageType = imageType,
maxWidth = 400,
maxHeight = 400,
quality = 90,
tag = imageTag,
)
val imageResult = imageLoader.execute(ImageRequest.Builder(context).data(url).build())
val drawable = imageResult.drawable ?: return null
return withContext(Dispatchers.IO) {
val bitmap = drawable.toBitmap().copy(Bitmap.Config.ARGB_8888, true)
Palette.from(bitmap).generate()
}
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path android:pathData="L40,0 40,40 0,40Z">
<aapt:attr name="android:fillColor">
<gradient
android:endColor="#00A4DC"
android:endX="70"
android:endY="70"
android:startColor="#AA5CC3"
android:startX="0"
android:startY="0"
android:type="linear" />
</aapt:attr>
</path>
<path
android:fillColor="#ffffff"
android:pathData="m20,10c-5.52,0 -10,4.48 -10,10s4.48 10 10 10 10-4.48 10-10-4.48-10-10-10zm0,14.5c-2.49,0 -4.5,-2.01 -4.5,-4.5s2.01-4.5 4.5-4.5 4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-0.55 0-1 0.45-1 1s0.45 1 1 1 1-0.45 1-1-0.45-1-1-1z" />
</vector>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path android:pathData="L40,0 40,40 0,40Z">
<aapt:attr name="android:fillColor">
<gradient
android:endColor="#00A4DC"
android:endX="70"
android:endY="70"
android:startColor="#AA5CC3"
android:startX="0"
android:startY="0"
android:type="linear" />
</aapt:attr>
</path>
<path
android:fillColor="#ffffff"
android:pathData="M20,19c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM20,22c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"
android:fillColor="#ffffff" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/theme_background" />
<item
android:drawable="@drawable/ic_launcher_foreground"
android:gravity="center" />
</layer-list>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.compose.ui.platform.ComposeView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/theme_background" />
+2
View File
@@ -3,6 +3,7 @@
<!-- Brand and base colors --> <!-- Brand and base colors -->
<color name="jellyfin_primary">#202020</color> <color name="jellyfin_primary">#202020</color>
<color name="jellyfin_primary_dark">#101010</color> <color name="jellyfin_primary_dark">#101010</color>
<!-- Secondary -->
<color name="jellyfin_accent">#00a4dc</color> <color name="jellyfin_accent">#00a4dc</color>
<!-- Launcher icon background --> <!-- Launcher icon background -->
@@ -10,6 +11,7 @@
<!-- App colors --> <!-- App colors -->
<color name="theme_background">#101010</color> <color name="theme_background">#101010</color>
<color name="theme_surface">#363636</color>
<color name="logo_text_color">#fafafa</color> <color name="logo_text_color">#fafafa</color>
<color name="error_text_color">#cf6679</color> <color name="error_text_color">#cf6679</color>
<color name="playback_controls_background">#60000000</color> <color name="playback_controls_background">#60000000</color>
+12
View File
@@ -23,6 +23,18 @@
<string name="choose_server_button_text">Choose server</string> <string name="choose_server_button_text">Choose server</string>
<string name="available_servers_title">Available servers</string> <string name="available_servers_title">Available servers</string>
<string name="authenticate_title">Authenticate with Server</string>
<string name="username_input_hint">Username</string>
<string name="password_input_hint">Password</string>
<string name="error_text_wrong_login">Wrong username or password</string>
<string name="login_button_text">Login</string>
<string name="profile_button_text">Profile</string>
<string name="logout_button_text">Logout</string>
<string name="library_music_tab_albums">Albums</string>
<string name="library_music_tab_artists">Artists</string>
<string name="library_music_tab_songs">Songs</string>
<string name="battery_optimizations_message">Please disable battery optimizations for media playback while the screen is off.</string> <string name="battery_optimizations_message">Please disable battery optimizations for media playback while the screen is off.</string>
<string name="network_title">Allowed Network Types</string> <string name="network_title">Allowed Network Types</string>
<string name="network_message">Do you want to allow the download to run over mobile data or roaming networks? Charges from your provider may apply.</string> <string name="network_message">Do you want to allow the download to run over mobile data or roaming networks? Charges from your provider may apply.</string>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name" translatable="false">Jellyfin</string> <string name="app_name" translatable="false">Jellyfin</string>
<string name="app_name_short" translatable="false">@string/app_name</string>
<string name="position_duration_divider" translatable="false">" / "</string> <string name="position_duration_divider" translatable="false">" / "</string>
</resources> </resources>
+1 -1
View File
@@ -4,7 +4,7 @@
<item name="colorPrimary">@color/jellyfin_primary</item> <item name="colorPrimary">@color/jellyfin_primary</item>
<item name="colorPrimaryDark">@color/jellyfin_primary_dark</item> <item name="colorPrimaryDark">@color/jellyfin_primary_dark</item>
<item name="colorAccent">@color/jellyfin_accent</item> <item name="colorAccent">@color/jellyfin_accent</item>
<item name="android:windowBackground">@color/theme_background</item> <item name="android:windowBackground">@drawable/splash_background</item>
</style> </style>
<style name="AppTheme" parent="AppTheme.V21" /> <style name="AppTheme" parent="AppTheme.V21" />