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

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Jellyfin Debug</string>
<string name="app_name_short" translatable="false">Jellyfin</string>
</resources>

View File

@@ -19,6 +19,7 @@ import org.jellyfin.mobile.player.PlayerEvent
import org.jellyfin.mobile.player.PlayerFragment
import org.jellyfin.mobile.player.source.MediaSourceResolver
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.ImageResolver
import org.jellyfin.mobile.utils.PermissionRequestHelper
import org.jellyfin.mobile.viewmodel.MainViewModel
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
@@ -35,6 +36,7 @@ val applicationModule = module {
single { AppPreferences(androidApplication()) }
single { OkHttpClient() }
single { ImageLoader(androidApplication()) }
single { ImageResolver(androidApplication(), get(), get()) }
single { PermissionRequestHelper() }
single { WebappFunctionChannel() }
single { RemoteVolumeProvider(get()) }

View File

@@ -2,8 +2,10 @@ package org.jellyfin.mobile
import android.app.Application
import android.webkit.WebView
import coil.Coil
import com.melegy.redscreenofdeath.RedScreenOfDeath
import org.jellyfin.mobile.api.apiModule
import org.jellyfin.mobile.controller.controllerModule
import org.jellyfin.mobile.model.databaseModule
import org.jellyfin.mobile.utils.JellyTree
import org.jellyfin.mobile.utils.isWebViewSupported
@@ -37,8 +39,14 @@ class JellyfinApplication : Application() {
modules(
applicationModule,
apiModule,
controllerModule,
databaseModule,
)
// Set Coil ImageLoader factory
Coil.setImageLoader {
koin.get()
}
}
}
}

View File

@@ -14,12 +14,12 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.mobile.cast.Chromecast
import org.jellyfin.mobile.cast.IChromecast
import org.jellyfin.mobile.fragment.ConnectFragment
import org.jellyfin.mobile.fragment.WebViewFragment
import org.jellyfin.mobile.player.PlayerFragment
import org.jellyfin.mobile.ui.MainFragment
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.PermissionRequestHelper
import org.jellyfin.mobile.utils.SmartOrientationListener
@@ -82,20 +82,26 @@ class MainActivity : AppCompatActivity() {
}
// Load UI
lifecycleScope.launchWhenStarted {
mainViewModel.serverState.collect { state ->
with(supportFragmentManager) {
when (state) {
ServerState.Pending -> {
// TODO add loading indicator
}
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)
})
if (true) {
with(supportFragmentManager) {
replaceFragment<MainFragment>()
}
} else {
lifecycleScope.launchWhenStarted {
mainViewModel.serverState.collect { state ->
with(supportFragmentManager) {
when (state) {
ServerState.Pending -> {
// TODO add loading indicator
}
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)
})
}
}
}
}

View File

@@ -13,6 +13,7 @@ import org.jellyfin.sdk.api.operations.PlayStateApi
import org.jellyfin.sdk.api.operations.PlaylistsApi
import org.jellyfin.sdk.api.operations.SystemApi
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.VideosApi
import org.jellyfin.sdk.discovery.AndroidBroadcastAddressesProvider
@@ -41,6 +42,7 @@ val apiModule = module {
single { SystemApi(get()) }
single { ItemsApi(get()) }
single { ImageApi(get()) }
single { UserApi(get()) }
single { UserViewsApi(get()) }
single { ArtistsApi(get()) }
single { GenresApi(get()) }

View File

@@ -79,7 +79,7 @@ class ApiController(
apiClient.deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId)
}
private fun resetApiClientUser() {
fun resetApiClientUser() {
apiClient.userId = null
apiClient.accessToken = null
apiClient.deviceInfo = baseDeviceInfo

View File

@@ -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()) }
}

View File

@@ -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",
)
}
}

View File

@@ -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
}
}

View File

@@ -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"),
}

View File

@@ -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"
}

View File

@@ -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?,
)

View File

@@ -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?,
)

View File

@@ -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?,
)

View File

@@ -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))

View File

@@ -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?,
)

View File

@@ -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?,
)

View File

@@ -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)
}

View File

@@ -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?,
)

View File

@@ -0,0 +1,8 @@
package org.jellyfin.mobile.model.state
enum class AuthState {
UNSET,
PENDING,
SUCCESS,
FAILURE,
}

View File

@@ -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()
}

View File

@@ -0,0 +1,7 @@
package org.jellyfin.mobile.model.state
enum class LoginState {
PENDING,
NOT_LOGGED_IN,
LOGGED_IN,
}

View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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))
}
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)
}
}
}
}
}

View File

@@ -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),
)
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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()
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
},
)
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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)
},
)
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)
}

View File

@@ -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()
}
}
}
}
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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,
)
}
}
}
*/

View File

@@ -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")
}

View File

@@ -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")
}
}

View File

@@ -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()
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -3,6 +3,7 @@
<!-- Brand and base colors -->
<color name="jellyfin_primary">#202020</color>
<color name="jellyfin_primary_dark">#101010</color>
<!-- Secondary -->
<color name="jellyfin_accent">#00a4dc</color>
<!-- Launcher icon background -->
@@ -10,6 +11,7 @@
<!-- App colors -->
<color name="theme_background">#101010</color>
<color name="theme_surface">#363636</color>
<color name="logo_text_color">#fafafa</color>
<color name="error_text_color">#cf6679</color>
<color name="playback_controls_background">#60000000</color>

View File

@@ -23,6 +23,18 @@
<string name="choose_server_button_text">Choose server</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="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>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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>
</resources>

View File

@@ -4,7 +4,7 @@
<item name="colorPrimary">@color/jellyfin_primary</item>
<item name="colorPrimaryDark">@color/jellyfin_primary_dark</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 name="AppTheme" parent="AppTheme.V21" />