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