From 30251b7dd4b0fb0b866cf7a988ba4885c503c8a2 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Wed, 21 Sep 2022 20:04:46 +0200 Subject: [PATCH] Rewrite connection screen in Jetpack Compose --- app/build.gradle.kts | 9 +- .../java/org/jellyfin/mobile/MainViewModel.kt | 12 +- .../mobile/app/ApiClientController.kt | 11 +- .../java/org/jellyfin/mobile/app/AppModule.kt | 6 +- .../jellyfin/mobile/setup/ConnectFragment.kt | 218 +----------- .../jellyfin/mobile/setup/ConnectionHelper.kt | 86 +++++ .../ui/screens/connect/ConnectScreen.kt | 70 ++++ .../ui/screens/connect/ServerSelection.kt | 323 ++++++++++++++++++ .../jellyfin/mobile/ui/state/CheckUrlState.kt | 8 + .../mobile/ui/state/ServerSelectionType.kt | 6 + .../org/jellyfin/mobile/ui/utils/AppTheme.kt | 41 +++ .../org/jellyfin/mobile/ui/utils/CenterRow.kt | 19 ++ app/src/main/res/layout/fragment_compose.xml | 6 + app/src/main/res/layout/fragment_connect.xml | 121 ------- app/src/main/res/values/colors.xml | 2 + .../res/values/strings_donottranslate.xml | 1 + detekt.yml | 3 + gradle/libs.versions.toml | 25 ++ 18 files changed, 629 insertions(+), 338 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/mobile/setup/ConnectionHelper.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/ui/state/CheckUrlState.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/ui/state/ServerSelectionType.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/ui/utils/CenterRow.kt create mode 100644 app/src/main/res/layout/fragment_compose.xml delete mode 100644 app/src/main/res/layout/fragment_connect.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 162f4e466f..73f7753c57 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,11 +81,15 @@ android { @Suppress("UnstableApiUsage") buildFeatures { viewBinding = true + compose = true } kotlinOptions { @Suppress("SuspiciousCollectionReassignment") freeCompilerArgs += listOf("-Xopt-in=kotlin.RequiresOptIn") } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } compileOptions { isCoreLibraryDesugaringEnabled = true } @@ -108,7 +112,7 @@ dependencies { implementation(libs.bundles.coroutines) // Core - implementation(libs.koin) + implementation(libs.bundles.koin) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) implementation(libs.androidx.activity) @@ -124,6 +128,9 @@ dependencies { implementation(libs.androidx.webkit) implementation(libs.modernandroidpreferences) + // Jetpack Compose + implementation(libs.bundles.compose) + // Network val sdkVersion = findProperty("sdk.version")?.toString() implementation(libs.jellyfin.sdk) { diff --git a/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt b/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt index fdf33f9c28..c60d5037ec 100644 --- a/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt @@ -18,14 +18,18 @@ class MainViewModel( init { viewModelScope.launch { - apiClientController.migrateFromPreferences() refreshServer() } } - suspend fun refreshServer() { - val server = apiClientController.loadSavedServer() - _serverState.value = server?.let { ServerState.Available(it) } ?: ServerState.Unset + suspend fun switchServer(hostname: String) { + apiClientController.setupServer(hostname) + refreshServer() + } + + private suspend fun refreshServer() { + val serverEntity = apiClientController.loadSavedServer() + _serverState.value = serverEntity?.let { entity -> ServerState.Available(entity) } ?: ServerState.Unset } } diff --git a/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt b/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt index db391f1311..e1725580c5 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt @@ -9,7 +9,6 @@ import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.serializer.toUUID -import java.util.* class ApiClientController( private val appPreferences: AppPreferences, @@ -22,16 +21,8 @@ class ApiClientController( get() = jellyfin.options.deviceInfo!! /** - * Migrate from preferences if necessary + * Store server with [hostname] in the database. */ - @Suppress("DEPRECATION") - suspend fun migrateFromPreferences() { - appPreferences.instanceUrl?.let { url -> - setupServer(url) - appPreferences.instanceUrl = null - } - } - suspend fun setupServer(hostname: String) { appPreferences.currentServerId = withContext(Dispatchers.IO) { serverDao.getServerByHostname(hostname)?.id ?: serverDao.insert(hostname) diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt index 3d98e52d37..b437792490 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt @@ -25,7 +25,7 @@ import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder import org.jellyfin.mobile.player.interaction.PlayerEvent import org.jellyfin.mobile.player.source.MediaSourceResolver import org.jellyfin.mobile.player.ui.PlayerFragment -import org.jellyfin.mobile.setup.ConnectFragment +import org.jellyfin.mobile.setup.ConnectionHelper import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.PermissionRequestHelper import org.jellyfin.mobile.utils.isLowRamDevice @@ -58,10 +58,12 @@ val applicationModule = module { viewModel { MainViewModel(get(), get()) } // Fragments - fragment { ConnectFragment() } fragment { WebViewFragment() } fragment { PlayerFragment() } + // Connection helper + single { ConnectionHelper(get(), get()) } + // Media player helpers single { MediaSourceResolver(get()) } single { DeviceProfileBuilder() } diff --git a/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt b/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt index 879453c35c..b8df34ee44 100644 --- a/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt @@ -1,228 +1,46 @@ package org.jellyfin.mobile.setup -import android.app.AlertDialog import android.os.Bundle -import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.Button -import android.widget.EditText -import android.widget.TextView -import androidx.core.content.getSystemService +import androidx.compose.ui.platform.ComposeView import androidx.core.view.ViewCompat -import androidx.core.view.doOnNextLayout -import androidx.core.view.isVisible -import androidx.core.view.postDelayed import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch import org.jellyfin.mobile.MainViewModel -import org.jellyfin.mobile.R -import org.jellyfin.mobile.app.ApiClientController -import org.jellyfin.mobile.databinding.FragmentConnectBinding +import org.jellyfin.mobile.databinding.FragmentComposeBinding +import org.jellyfin.mobile.ui.screens.connect.ConnectScreen +import org.jellyfin.mobile.ui.utils.AppTheme import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins -import org.jellyfin.sdk.Jellyfin -import org.jellyfin.sdk.discovery.LocalServerDiscovery -import org.jellyfin.sdk.discovery.RecommendedServerInfo -import org.jellyfin.sdk.discovery.RecommendedServerInfoScore -import org.jellyfin.sdk.model.api.ServerDiscoveryInfo -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel -import timber.log.Timber class ConnectFragment : Fragment() { private val mainViewModel: MainViewModel by sharedViewModel() - private val jellyfin: Jellyfin by inject() - private val apiClientController: ApiClientController by inject() + private var _viewBinding: FragmentComposeBinding? = null + private val viewBinding get() = _viewBinding!! + private val composeView: ComposeView get() = viewBinding.composeView - // UI - private var _connectServerBinding: FragmentConnectBinding? = null - private val connectServerBinding get() = _connectServerBinding!! - private val serverSetupLayout: View get() = connectServerBinding.root - private val hostInput: EditText get() = connectServerBinding.hostInput - private val connectionErrorText: TextView get() = connectServerBinding.connectionErrorText - private val connectButton: Button get() = connectServerBinding.connectButton - private val chooseServerButton: Button get() = connectServerBinding.chooseServerButton - private val connectionProgress: View get() = connectServerBinding.connectionProgress - - private val serverList = ArrayList(LocalServerDiscovery.DISCOVERY_MAX_SERVERS) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _connectServerBinding = FragmentConnectBinding.inflate(inflater, container, false) - return serverSetupLayout.apply { applyWindowInsetsAsMargins() } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _viewBinding = FragmentComposeBinding.inflate(inflater, container, false) + return composeView.apply { applyWindowInsetsAsMargins() } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // Apply window insets - ViewCompat.requestApplyInsets(serverSetupLayout) - - hostInput.setText(mainViewModel.serverState.value.server?.hostname) - hostInput.setSelection(hostInput.length()) - hostInput.setOnEditorActionListener { _, action, event -> - when { - action == EditorInfo.IME_ACTION_DONE || event.keyCode == KeyEvent.KEYCODE_ENTER -> { - connect() - true - } - else -> false - } - } - connectButton.setOnClickListener { - connect() - } - chooseServerButton.setOnClickListener { - chooseServer() - } - - if (arguments?.getBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR) == true) - showConnectionError() - - // Show keyboard - serverSetupLayout.doOnNextLayout { - @Suppress("MagicNumber") - hostInput.postDelayed(25) { - hostInput.requestFocus() - - requireContext().getSystemService()?.showSoftInput(hostInput, InputMethodManager.SHOW_IMPLICIT) - } - } + ViewCompat.requestApplyInsets(composeView) - discoverServers() - } - - override fun onDestroyView() { - super.onDestroyView() - _connectServerBinding = null - } + val encounteredConnectionError = arguments?.getBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR) == true - private fun connect(enteredUrl: String = hostInput.text.toString()) { - hostInput.isEnabled = false - connectButton.isVisible = false - connectionProgress.isVisible = true - chooseServerButton.isVisible = false - clearConnectionError() - lifecycleScope.launch { - val httpUrl = checkServerUrlAndConnection(enteredUrl) - if (httpUrl != null) { - serverList.clear() - apiClientController.setupServer(httpUrl) - mainViewModel.refreshServer() + composeView.setContent { + AppTheme { + ConnectScreen( + mainViewModel = mainViewModel, + showExternalConnectionError = encounteredConnectionError, + ) } - hostInput.isEnabled = true - connectButton.isVisible = true - connectionProgress.isVisible = false - chooseServerButton.isVisible = serverList.isNotEmpty() } } - - private fun discoverServers() { - lifecycleScope.launch { - jellyfin.discovery - .discoverLocalServers(maxServers = LocalServerDiscovery.DISCOVERY_MAX_SERVERS) - .flowOn(Dispatchers.IO) - .collect { serverInfo -> - serverList.add(serverInfo) - // Only show server chooser when not connecting already - if (connectButton.isVisible) chooseServerButton.isVisible = true - } - } - } - - private fun chooseServer() { - AlertDialog.Builder(activity).apply { - setTitle(R.string.available_servers_title) - setItems(serverList.map { "${it.name}\n${it.address}" }.toTypedArray()) { _, index -> - connect(serverList[index].address) - } - }.show() - } - - private fun showConnectionError(error: String? = null) { - connectionErrorText.apply { - text = error ?: getText(R.string.connection_error_cannot_connect) - isVisible = true - } - } - - private fun clearConnectionError() { - connectionErrorText.apply { - text = null - isVisible = false - } - } - - private suspend fun checkServerUrlAndConnection(enteredUrl: String): String? { - 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() - val goodServers = mutableListOf() - 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.getOrNull()) - Timber.i("Found valid server at ${server.address} with rating ${server.score} and version ${systemInfo.version}") - return server.address - } - - // 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.getOrNull() == null } - - StringBuilder(resources.getQuantityString(R.plurals.connection_error_prefix, count, count)).apply { - if (unreachableServers.isNotEmpty()) { - append("\n\n") - append(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(getString(R.string.connection_error_unsupported_version_or_product)) - append(":\n") - append(incompatibleServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" }) - } - }.toString() - } else null - - showConnectionError(error) - return null - } } diff --git a/app/src/main/java/org/jellyfin/mobile/setup/ConnectionHelper.kt b/app/src/main/java/org/jellyfin/mobile/setup/ConnectionHelper.kt new file mode 100644 index 0000000000..e7c7453000 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/setup/ConnectionHelper.kt @@ -0,0 +1,86 @@ +package org.jellyfin.mobile.setup + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOn +import org.jellyfin.mobile.R +import org.jellyfin.mobile.ui.state.CheckUrlState +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.discovery.LocalServerDiscovery +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.api.ServerDiscoveryInfo +import timber.log.Timber + +class ConnectionHelper( + private val context: Context, + private val jellyfin: Jellyfin, +) { + 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() + val goodServers = mutableListOf() + 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.getOrNull()?.version}") + return CheckUrlState.Success(server.address) + } + + // 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.getOrNull() == 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) + } + + fun discoverServersAsFlow(): Flow = + jellyfin.discovery + .discoverLocalServers(maxServers = LocalServerDiscovery.DISCOVERY_MAX_SERVERS) + .flowOn(Dispatchers.IO) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt new file mode 100644 index 0000000000..8544c8c40d --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt @@ -0,0 +1,70 @@ +package org.jellyfin.mobile.ui.screens.connect + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +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.unit.dp +import org.jellyfin.mobile.MainViewModel +import org.jellyfin.mobile.R +import org.jellyfin.mobile.ui.utils.CenterRow + +@Composable +fun ConnectScreen( + mainViewModel: MainViewModel, + showExternalConnectionError: Boolean, +) { + Surface(color = MaterialTheme.colors.background) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + LogoHeader() + ServerSelection( + showExternalConnectionError = showExternalConnectionError, + onConnected = { hostname -> + mainViewModel.switchServer(hostname) + }, + ) + } + } +} + +@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 = @Suppress("MagicNumber") 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, + ) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt new file mode 100644 index 0000000000..d9b160a30b --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt @@ -0,0 +1,323 @@ +package org.jellyfin.mobile.ui.screens.connect + +import android.view.KeyEvent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ListItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.jellyfin.mobile.R +import org.jellyfin.mobile.setup.ConnectionHelper +import org.jellyfin.mobile.ui.state.CheckUrlState +import org.jellyfin.mobile.ui.state.ServerSelectionType +import org.jellyfin.mobile.ui.utils.CenterRow +import org.jellyfin.sdk.model.api.ServerDiscoveryInfo +import org.koin.androidx.compose.get + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ServerSelection( + showExternalConnectionError: Boolean, + connectionHelper: ConnectionHelper = get(), + onConnected: suspend (String) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + var serverSelectionType by remember { mutableStateOf(ServerSelectionType.ADDRESS) } + var hostname by remember { mutableStateOf("") } + var checkUrlState by remember> { mutableStateOf(CheckUrlState.Unchecked) } + var externalError by remember { mutableStateOf(showExternalConnectionError) } + + val discoveredServers = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + connectionHelper.discoverServersAsFlow().collect { serverInfo -> + discoveredServers.add(serverInfo) + } + } + + fun onSubmit() { + externalError = false + checkUrlState = CheckUrlState.Pending + coroutineScope.launch { + val state = connectionHelper.checkServerUrl(hostname) + checkUrlState = state + if (state is CheckUrlState.Success) { + onConnected(state.address) + } + } + } + + Column { + Text( + text = stringResource(R.string.connect_to_server_title), + modifier = Modifier.padding(bottom = 8.dp), + style = MaterialTheme.typography.h5, + ) + Crossfade(serverSelectionType) { selectionType -> + when (selectionType) { + ServerSelectionType.ADDRESS -> AddressSelection( + text = hostname, + errorText = when { + externalError -> stringResource(R.string.connection_error_cannot_connect) + else -> (checkUrlState as? CheckUrlState.Error)?.message + }, + loading = checkUrlState is CheckUrlState.Pending, + onTextChange = { value -> + externalError = false + checkUrlState = CheckUrlState.Unchecked + hostname = value + }, + onDiscoveryClick = { + externalError = false + keyboardController?.hide() + serverSelectionType = ServerSelectionType.AUTO_DISCOVERY + }, + onSubmit = { + onSubmit() + }, + ) + ServerSelectionType.AUTO_DISCOVERY -> ServerDiscoveryList( + discoveredServers = discoveredServers, + onGoBack = { + serverSelectionType = ServerSelectionType.ADDRESS + }, + onSelectServer = { url -> + hostname = url + serverSelectionType = ServerSelectionType.ADDRESS + onSubmit() + }, + ) + } + } + } +} + +@Stable +@Composable +private fun AddressSelection( + text: String, + errorText: String?, + loading: Boolean, + onTextChange: (String) -> Unit, + onDiscoveryClick: () -> Unit, + onSubmit: () -> Unit, +) { + Column { + ServerUrlField( + text = text, + errorText = errorText, + onTextChange = onTextChange, + onSubmit = onSubmit, + ) + AnimatedErrorText(errorText = errorText) + if (!loading) { + Spacer(modifier = Modifier.height(12.dp)) + StyledTextButton( + text = stringResource(R.string.connect_button_text), + enabled = text.isNotBlank(), + onClick = onSubmit, + ) + StyledTextButton( + text = stringResource(R.string.choose_server_button_text), + onClick = onDiscoveryClick, + ) + } else { + CenterRow { + CircularProgressIndicator(modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)) + } + } + } +} + +@Stable +@Composable +private fun ServerUrlField( + text: String, + errorText: String?, + onTextChange: (String) -> Unit, + onSubmit: () -> Unit, +) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .onKeyEvent { keyEvent -> + when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_ENTER -> { + onSubmit() + true + } + else -> false + } + }, + label = { + Text(text = stringResource(R.string.host_input_hint)) + }, + isError = errorText != null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions( + onGo = { + onSubmit() + }, + ), + singleLine = true, + ) +} + +@Stable +@Composable +private fun AnimatedErrorText( + errorText: String?, +) { + AnimatedVisibility( + visible = errorText != null, + exit = ExitTransition.None, + ) { + Text( + text = errorText.orEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption, + ) + } +} + +@Stable +@Composable +private fun StyledTextButton( + text: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + enabled = enabled, + colors = ButtonDefaults.buttonColors(), + ) { + Text(text = text) + } +} + +@Stable +@Composable +private fun ServerDiscoveryList( + discoveredServers: SnapshotStateList, + onGoBack: () -> Unit, + onSelectServer: (String) -> Unit, +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onGoBack) { + Icon(imageVector = Icons.Outlined.ArrowBack, contentDescription = null) + } + Text( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + text = stringResource(R.string.available_servers_title), + ) + CircularProgressIndicator( + modifier = Modifier + .padding(horizontal = 12.dp) + .size(24.dp), + ) + } + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn( + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxSize() + .background( + color = MaterialTheme.colors.surface, + shape = MaterialTheme.shapes.medium, + ), + ) { + items(discoveredServers) { server -> + ServerDiscoveryItem( + serverInfo = server, + onClickServer = { + onSelectServer(server.address) + }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Stable +@Composable +private fun ServerDiscoveryItem( + serverInfo: ServerDiscoveryInfo, + onClickServer: () -> Unit, +) { + ListItem( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClickServer), + text = { + Text(text = serverInfo.name) + }, + secondaryText = { + Text(text = serverInfo.address) + }, + ) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/state/CheckUrlState.kt b/app/src/main/java/org/jellyfin/mobile/ui/state/CheckUrlState.kt new file mode 100644 index 0000000000..f6a9743efb --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/state/CheckUrlState.kt @@ -0,0 +1,8 @@ +package org.jellyfin.mobile.ui.state + +sealed class CheckUrlState { + object Unchecked : CheckUrlState() + object Pending : CheckUrlState() + class Success(val address: String) : CheckUrlState() + class Error(val message: String?) : CheckUrlState() +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/state/ServerSelectionType.kt b/app/src/main/java/org/jellyfin/mobile/ui/state/ServerSelectionType.kt new file mode 100644 index 0000000000..2ebb1fd653 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/state/ServerSelectionType.kt @@ -0,0 +1,6 @@ +package org.jellyfin.mobile.ui.state + +enum class ServerSelectionType { + ADDRESS, + AUTO_DISCOVERY, +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt new file mode 100644 index 0000000000..bbd32a4785 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt @@ -0,0 +1,41 @@ +package org.jellyfin.mobile.ui.utils + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes +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.compose.ui.unit.dp +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_accent)), + primaryVariant = Color(ContextCompat.getColor(context, R.color.jellyfin_primary)), + 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, + shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(0.dp), + ), + content = content, + ) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/CenterRow.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/CenterRow.kt new file mode 100644 index 0000000000..13d871e4a0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/CenterRow.kt @@ -0,0 +1,19 @@ +package org.jellyfin.mobile.ui.utils + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +inline fun CenterRow( + content: @Composable RowScope.() -> Unit, +) = Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content, +) diff --git a/app/src/main/res/layout/fragment_compose.xml b/app/src/main/res/layout/fragment_compose.xml new file mode 100644 index 0000000000..f33826a9e1 --- /dev/null +++ b/app/src/main/res/layout/fragment_compose.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/fragment_connect.xml b/app/src/main/res/layout/fragment_connect.xml deleted file mode 100644 index 7dbf637d2d..0000000000 --- a/app/src/main/res/layout/fragment_connect.xml +++ /dev/null @@ -1,121 +0,0 @@ - - - - - - - - - - - - - -