mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2025-12-23 23:37:53 -05:00
WIP: Native App UI with compose
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
38
app/src/main/java/org/jellyfin/mobile/model/BaseItemKind.kt
Normal file
38
app/src/main/java/org/jellyfin/mobile/model/BaseItemKind.kt
Normal 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"),
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
13
app/src/main/java/org/jellyfin/mobile/model/dto/Album.kt
Normal file
13
app/src/main/java/org/jellyfin/mobile/model/dto/Album.kt
Normal 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?,
|
||||
)
|
||||
11
app/src/main/java/org/jellyfin/mobile/model/dto/Artist.kt
Normal file
11
app/src/main/java/org/jellyfin/mobile/model/dto/Artist.kt
Normal 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?,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
13
app/src/main/java/org/jellyfin/mobile/model/dto/Song.kt
Normal file
13
app/src/main/java/org/jellyfin/mobile/model/dto/Song.kt
Normal 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?,
|
||||
)
|
||||
15
app/src/main/java/org/jellyfin/mobile/model/dto/UserInfo.kt
Normal file
15
app/src/main/java/org/jellyfin/mobile/model/dto/UserInfo.kt
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
138
app/src/main/java/org/jellyfin/mobile/ui/App.kt
Normal file
138
app/src/main/java/org/jellyfin/mobile/ui/App.kt
Normal 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"
|
||||
}
|
||||
104
app/src/main/java/org/jellyfin/mobile/ui/Common.kt
Normal file
104
app/src/main/java/org/jellyfin/mobile/ui/Common.kt
Normal 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)
|
||||
}
|
||||
32
app/src/main/java/org/jellyfin/mobile/ui/KoinHelpers.kt
Normal file
32
app/src/main/java/org/jellyfin/mobile/ui/KoinHelpers.kt
Normal 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()
|
||||
}
|
||||
42
app/src/main/java/org/jellyfin/mobile/ui/MainFragment.kt
Normal file
42
app/src/main/java/org/jellyfin/mobile/ui/MainFragment.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
298
app/src/main/java/org/jellyfin/mobile/ui/screen/SetupScreen.kt
Normal file
298
app/src/main/java/org/jellyfin/mobile/ui/screen/SetupScreen.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
app/src/main/java/org/jellyfin/mobile/ui/utils/ApiImage.kt
Normal file
118
app/src/main/java/org/jellyfin/mobile/ui/utils/ApiImage.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
31
app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt
Normal file
31
app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt
Normal 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)
|
||||
}
|
||||
86
app/src/main/java/org/jellyfin/mobile/ui/utils/GridList.kt
Normal file
86
app/src/main/java/org/jellyfin/mobile/ui/utils/GridList.kt
Normal 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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
36
app/src/main/java/org/jellyfin/mobile/ui/utils/ViewModel.kt
Normal file
36
app/src/main/java/org/jellyfin/mobile/ui/utils/ViewModel.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
40
app/src/main/java/org/jellyfin/mobile/utils/ImageResolver.kt
Normal file
40
app/src/main/java/org/jellyfin/mobile/utils/ImageResolver.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/src/main/res/drawable/fallback_image_album_cover.xml
Normal file
23
app/src/main/res/drawable/fallback_image_album_cover.xml
Normal 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>
|
||||
23
app/src/main/res/drawable/fallback_image_person.xml
Normal file
23
app/src/main/res/drawable/fallback_image_person.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_arrow_back_white_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_arrow_back_white_24dp.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_folder_white_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_folder_white_24dp.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_overflow_white_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_overflow_white_24dp.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/splash_background.xml
Normal file
7
app/src/main/res/drawable/splash_background.xml
Normal 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>
|
||||
6
app/src/main/res/layout/fragment_main.xml
Normal file
6
app/src/main/res/layout/fragment_main.xml
Normal 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" />
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user