diff --git a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt index cc8d7831..aa32e71e 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt @@ -19,14 +19,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore +import androidx.navigation3.runtime.rememberNavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.dolphin.data.ServerRepository import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.CoilConfig -import com.github.damontecres.dolphin.ui.ServerLoginPage import com.github.damontecres.dolphin.ui.nav.ApplicationContent +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.ui.setup.SwitchServerContent +import com.github.damontecres.dolphin.ui.setup.SwitchUserContent import com.github.damontecres.dolphin.ui.theme.DolphinTheme import com.github.damontecres.dolphin.util.profile.createDeviceProfile import dagger.hilt.android.AndroidEntryPoint @@ -69,13 +73,19 @@ class MainActivity : AppCompatActivity() { isRestoringSession = false } val deviceProfile = - remember { + remember(preferences) { createDeviceProfile(this@MainActivity, preferences, false) } val server = serverRepository.currentServer val user = serverRepository.currentUser + val backStack = rememberNavBackStack(Destination.Main) + val navigationManager = NavigationManager(backStack) + if (server != null && user != null) { ApplicationContent( + user = user, + server = server, + navigationManager = navigationManager, preferences = preferences, deviceProfile = deviceProfile, modifier = Modifier.fillMaxSize(), @@ -90,8 +100,17 @@ class MainActivity : AppCompatActivity() { modifier = Modifier.align(Alignment.Center), ) } + } else if (server != null) { + // Have server but no user, go to user selection + SwitchUserContent( + navigationManager = navigationManager, + modifier = Modifier.fillMaxSize(), + ) } else { - ServerLoginPage(modifier = Modifier.fillMaxSize()) + SwitchServerContent( + navigationManager = navigationManager, + modifier = Modifier.fillMaxSize(), + ) } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/dolphin/data/JellyfinServer.kt index f3fbe8d5..170660eb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/JellyfinServer.kt @@ -51,7 +51,7 @@ data class JellyfinServerUsers( @Dao interface JellyfinServerDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Insert(onConflict = OnConflictStrategy.IGNORE) fun addServer(vararg servers: JellyfinServer) @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt index b3ce7ecb..1640569f 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt @@ -3,11 +3,14 @@ package com.github.damontecres.dolphin.data import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.datastore.core.DataStore +import com.github.damontecres.dolphin.preferences.UserPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.model.api.AuthenticationResult import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -18,6 +21,7 @@ class ServerRepository constructor( val serverDao: JellyfinServerDao, val apiClient: ApiClient, + val userPreferencesDataStore: DataStore, ) { private var _currentServer by mutableStateOf(null) val currentServer get() = _currentServer @@ -103,4 +107,44 @@ class ServerRepository } return false } + + suspend fun changeUser( + serverUrl: String, + authenticationResult: AuthenticationResult, + ) { + val accessToken = authenticationResult.accessToken + if (accessToken != null) { + val authedUser = authenticationResult.user + val server = + authenticationResult.serverId?.let { + JellyfinServer( + id = it, + name = authedUser?.serverName, + url = serverUrl, + ) + } + if (server != null) { + val user = + authedUser?.let { + JellyfinUser( + id = it.id.toString(), + name = it.name, + serverId = server.id, + accessToken = accessToken, + ) + } + if (user != null) { + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = server.id + currentUserId = user.id + }.build() + } + addAndChangeUser(server, user) + } + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/ServerManagement.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/ServerManagement.kt deleted file mode 100644 index b3aa35dd..00000000 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/ServerManagement.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.github.damontecres.dolphin.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.datastore.core.DataStore -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.tv.material3.Button -import androidx.tv.material3.Text -import com.github.damontecres.dolphin.data.JellyfinServer -import com.github.damontecres.dolphin.data.JellyfinUser -import com.github.damontecres.dolphin.data.ServerRepository -import com.github.damontecres.dolphin.preferences.UserPreferences -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import org.jellyfin.sdk.Jellyfin -import org.jellyfin.sdk.api.client.exception.InvalidStatusException -import org.jellyfin.sdk.api.client.extensions.authenticateUserByName -import org.jellyfin.sdk.api.client.extensions.userApi -import javax.inject.Inject - -@HiltViewModel -class ServerManagementViewModel - @Inject - constructor( - val serverRepository: ServerRepository, - val userPreferencesDataStore: DataStore, - val jellyfin: Jellyfin, - ) : ViewModel() { - fun addServer() { - } - - suspend fun loginWithPassword( - serverUrl: String, - username: String, - password: String, - ) { - try { - val api = jellyfin.createApi(baseUrl = serverUrl) - val authenticationResult by api.userApi.authenticateUserByName( - username = username, - password = password, - ) - - val accessToken = authenticationResult.accessToken - if (accessToken != null) { - val authedUser = authenticationResult.user - val server = - authenticationResult.serverId?.let { - JellyfinServer( - id = it, - name = authedUser?.serverName, - url = serverUrl, - ) - } - if (server != null) { - val user = - authedUser?.let { - JellyfinUser( - id = it.id.toString(), - name = it.name, - serverId = server.id, - accessToken = accessToken, - ) - } - if (user != null) { - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = server.id - currentUserId = user.id - }.build() - } - serverRepository.addAndChangeUser(server, user) - } - } - } - - // Print session information - println(authenticationResult.sessionInfo) - } catch (err: InvalidStatusException) { - if (err.status == 401) { - // Username or password is incorrect - println("Invalid user") - } - } - } - } - -@Composable -fun ServerLoginPage( - modifier: Modifier = Modifier, - viewModel: ServerManagementViewModel = viewModel(), -) { - val scope = rememberCoroutineScope() - ServerLoginForm( - modifier = modifier, - onSubmit = { serverUrl, username, password -> - scope.launch { - // Handle form submission - viewModel.loginWithPassword(serverUrl, username, password) - } - }, - ) -} - -@Composable -fun ServerLoginForm( - modifier: Modifier = Modifier, - onSubmit: (serverUrl: String, username: String, password: String) -> Unit, -) { - var serverUrl by remember { mutableStateOf("") } - var username by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - - Column(modifier = modifier.padding(16.dp)) { - TextField( - value = serverUrl, - onValueChange = { serverUrl = it }, - label = { Text("Server URL") }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(8.dp)) - TextField( - value = username, - onValueChange = { username = it }, - label = { Text("Username") }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(8.dp)) - TextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { onSubmit(serverUrl, username, password) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Submit") - } - } -} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt index 53b61a49..f391f5a3 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt @@ -1,8 +1,10 @@ package com.github.damontecres.dolphin.ui +import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview import com.github.damontecres.dolphin.R import org.jellyfin.sdk.model.api.ItemFields @@ -24,3 +26,10 @@ val DefaultItemFields = ItemFields.SEASON_USER_DATA, ItemFields.CHILD_COUNT, ) + +@Preview( + device = "spec:parent=tv_1080p", + backgroundColor = 0xFF383535, + uiMode = UI_MODE_TYPE_TELEVISION, +) +annotation class PreviewTvSpec diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt index 0541c638..2980aedd 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.height @@ -46,8 +47,6 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.dolphin.ui.FontAwesome import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds sealed interface DialogItemEntry @@ -266,33 +265,24 @@ fun ScrollableDialog( } @Composable -fun MarkerDurationDialog( +fun BasicDialog( onDismissRequest: () -> Unit, - onClick: (Long) -> Unit, + properties: DialogProperties = DialogProperties(), + content: @Composable () -> Unit, ) { - val dialogItems = - remember { - listOf( - 15.seconds, - 20.seconds, - 30.seconds, - 60.seconds, - 5.minutes, - 10.minutes, - 20.minutes, - ).map { - DialogItem(it.toString()) { - onClick.invoke(it.inWholeMilliseconds) - } - } - } - DialogPopup( - showDialog = true, - title = "How long?", - dialogItems = dialogItems, + Dialog( onDismissRequest = onDismissRequest, - dismissOnClick = false, - waitToLoad = false, - properties = DialogProperties(), - ) + properties = properties, + ) { + Box( + modifier = + Modifier + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + content() + } + } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt index 9c10d6b2..d9fc84ac 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt @@ -5,40 +5,26 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import com.github.damontecres.dolphin.data.JellyfinServer +import com.github.damontecres.dolphin.data.JellyfinUser import com.github.damontecres.dolphin.preferences.UserPreferences import org.jellyfin.sdk.model.api.DeviceProfile @Composable fun ApplicationContent( + server: JellyfinServer, + user: JellyfinUser, + navigationManager: NavigationManager, preferences: UserPreferences, deviceProfile: DeviceProfile, modifier: Modifier = Modifier, ) { - val backStack = rememberNavBackStack(Destination.Main) - - val navigationManager = - object : NavigationManager { - override fun navigateTo(destination: Destination) { - backStack.add(destination) - } - - override fun goBack() { - backStack.removeLastOrNull() - } - - override fun goToHome() { - backStack.clear() - backStack.add(Destination.Main) - } - } - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, + backStack = navigationManager.backStack, + onBack = { navigationManager.goBack() }, entryDecorators = listOf( rememberSceneSetupNavEntryDecorator(), @@ -46,8 +32,9 @@ fun ApplicationContent( rememberViewModelStoreNavEntryDecorator(), ), entryProvider = { key -> - NavEntry(key) { - key as Destination + key as Destination + val contentKey = "${key}_${server.id}_${user.id}" + NavEntry(key, contentKey = contentKey) { if (key.fullScreen) { DestinationContent( destination = key, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt index edd5a9ab..1f70d75c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt @@ -15,6 +15,12 @@ sealed class Destination( @Serializable data object Setup : Destination(true) + @Serializable + data object ServerList : Destination(true) + + @Serializable + data object UserList : Destination(true) + @Serializable data object Main : Destination() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index fadfc1c8..e8e1687d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -6,6 +6,8 @@ import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.detail.MediaItemContent import com.github.damontecres.dolphin.ui.main.MainPage import com.github.damontecres.dolphin.ui.playback.PlaybackContent +import com.github.damontecres.dolphin.ui.setup.SwitchServerContent +import com.github.damontecres.dolphin.ui.setup.SwitchUserContent import org.jellyfin.sdk.model.api.DeviceProfile @Composable @@ -17,23 +19,22 @@ fun DestinationContent( modifier: Modifier = Modifier, ) { when (destination) { - Destination.Main -> { + Destination.Main -> MainPage( preferences = preferences, navigationManager = navigationManager, modifier = modifier, ) - } - is Destination.MediaItem -> { + + is Destination.MediaItem -> MediaItemContent( preferences = preferences, navigationManager = navigationManager, destination = destination, modifier = modifier, ) - } - is Destination.Playback -> { + is Destination.Playback -> PlaybackContent( preferences = preferences, navigationManager = navigationManager, @@ -41,7 +42,10 @@ fun DestinationContent( destination = destination, modifier = modifier, ) - } + + Destination.ServerList -> SwitchServerContent(navigationManager, modifier) + Destination.UserList -> SwitchUserContent(navigationManager, modifier) + Destination.Search -> TODO() Destination.Settings -> TODO() Destination.Setup -> TODO() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt index 74364ccd..dca44ea8 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt @@ -124,7 +124,7 @@ fun NavDrawer( IconNavItem( text = user?.name ?: "", icon = Icons.Default.AccountCircle, - onClick = { navigationManager.navigateTo(Destination.Setup) }, + onClick = { navigationManager.navigateTo(Destination.UserList) }, ) } item { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt index b458256f..ccb82cfb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt @@ -1,9 +1,21 @@ package com.github.damontecres.dolphin.ui.nav -interface NavigationManager { - fun navigateTo(destination: Destination) +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey - fun goBack() +class NavigationManager( + val backStack: NavBackStack, +) { + fun navigateTo(destination: Destination) { + backStack.add(destination) + } - fun goToHome() + fun goBack() { + backStack.removeLastOrNull() + } + + fun goToHome() { + backStack.clear() + backStack.add(Destination.Main) + } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt new file mode 100644 index 00000000..f0e31c1e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt @@ -0,0 +1,78 @@ +package com.github.damontecres.dolphin.ui.setup + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.data.JellyfinServer +import com.github.damontecres.dolphin.ui.components.CircularProgress + +sealed interface ServerConnectionStatus { + object Success : ServerConnectionStatus + + object Pending : ServerConnectionStatus + + data class Error( + val message: String?, + ) : ServerConnectionStatus +} + +@Composable +fun ServerList( + servers: List, + connectionStatus: Map, + onSwitchServer: (JellyfinServer) -> Unit, + onAddServer: () -> Unit, + onRemoveServer: (JellyfinServer) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + items(servers) { server -> + val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending + ListItem( + enabled = status == ServerConnectionStatus.Success, + selected = false, + headlineContent = { Text(text = server.name ?: server.id) }, + supportingContent = { Text(text = server.url) }, + leadingContent = { + when (status) { + ServerConnectionStatus.Success -> {} + ServerConnectionStatus.Pending -> { + CircularProgress() + } + is ServerConnectionStatus.Error -> { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = status.message, + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + onClick = { onSwitchServer.invoke(server) }, + onLongClick = { + // TODO dialog to remove server + }, + modifier = Modifier, + ) + } + item { + HorizontalDivider() + ListItem( + enabled = true, + selected = false, + headlineContent = { Text(text = "Add User") }, + supportingContent = { }, + onClick = { onAddServer.invoke() }, + modifier = Modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchServerContent.kt new file mode 100644 index 00000000..fb3288a7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchServerContent.kt @@ -0,0 +1,104 @@ +package com.github.damontecres.dolphin.ui.setup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.Button +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.ui.components.BasicDialog +import com.github.damontecres.dolphin.ui.components.EditTextBox +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager + +@Composable +fun SwitchServerContent( + navigationManager: NavigationManager, + modifier: Modifier = Modifier, + viewModel: SwitchUserViewModel = hiltViewModel(), +) { + val currentServer = viewModel.serverRepository.currentServer + val servers by viewModel.servers.observeAsState(listOf()) + val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) + + var showAddServer by remember { mutableStateOf(false) } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = "Select Server", + ) + ServerList( + servers = servers, + connectionStatus = serverStatus, + onSwitchServer = {}, + onAddServer = { + }, + onRemoveServer = { + // TODO + }, + modifier = Modifier.fillMaxWidth(.5f), + ) + } + + if (showAddServer) { + var url by remember { mutableStateOf("") } + val submit = { + showAddServer = false + viewModel.addServer(url) { + navigationManager.navigateTo(Destination.UserList) + } + } + BasicDialog( + onDismissRequest = { showAddServer = false }, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Enter Server URL", + ) + EditTextBox( + value = url, + onValueChange = { url = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + ), + keyboardActions = + KeyboardActions( + onDone = { submit.invoke() }, + ), + modifier = Modifier, + ) + Button( + onClick = { submit.invoke() }, + modifier = Modifier, + ) { + Text(text = "Submit") + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserContent.kt new file mode 100644 index 00000000..6be746af --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserContent.kt @@ -0,0 +1,182 @@ +package com.github.damontecres.dolphin.ui.setup + +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.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.ui.components.BasicDialog +import com.github.damontecres.dolphin.ui.components.EditTextBox +import com.github.damontecres.dolphin.ui.isNotNullOrBlank +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager + +@Composable +fun SwitchUserContent( + navigationManager: NavigationManager, + modifier: Modifier = Modifier, + viewModel: SwitchUserViewModel = hiltViewModel(), +) { + val currentServer = viewModel.serverRepository.currentServer + val currentUser = viewModel.serverRepository.currentUser + val users by viewModel.users.observeAsState(listOf()) + + val quickConnectEnabled by viewModel.quickConnectEnabled.observeAsState(false) + val quickConnect by viewModel.quickConnectState.observeAsState(null) + var showAddUser by remember { mutableStateOf(false) } + + currentServer?.let { server -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = server.name ?: server.url, + ) + Text( + text = "Select User", + ) + UserList( + users = users, + currentUser = currentUser, + onSwitchUser = { user -> + viewModel.switchUser(server, user) { + navigationManager.goToHome() + } + }, + onAddUser = { showAddUser = true }, + onRemoveUser = { user -> + }, + onSwitchServer = { + navigationManager.navigateTo(Destination.ServerList) + }, + modifier = Modifier.fillMaxWidth(.5f), + ) + } + + if (showAddUser) { + var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } + LaunchedEffect(Unit) { + if (quickConnectEnabled) { + viewModel.initiateQuickConnect(server) { + navigationManager.goToHome() + } + } + } + BasicDialog( + onDismissRequest = { + viewModel.cancelQuickConnect() + showAddUser = false + }, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp).fillMaxWidth(), + ) { + if (useQuickConnect) { + quickConnect?.let { qc -> + Text( + text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = qc.code, + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Button( + onClick = { + useQuickConnect = false + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text(text = "Use username/password") + } + } + } else { + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + val onSubmit = { + viewModel.login(server, username, password) { + showAddUser = false + navigationManager.goToHome() + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + Text( + text = "Username", + modifier = Modifier, + ) + EditTextBox( + value = username, + onValueChange = { username = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + ), + modifier = Modifier, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + Text( + text = "Password", + modifier = Modifier, + ) + EditTextBox( + value = password, + onValueChange = { password = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + keyboardActions = + KeyboardActions( + onDone = { onSubmit.invoke() }, + ), + modifier = Modifier, + ) + } + Button( + onClick = { onSubmit.invoke() }, + enabled = username.isNotNullOrBlank() && password.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text("Login") + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt new file mode 100644 index 00000000..b744336a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt @@ -0,0 +1,216 @@ +package com.github.damontecres.dolphin.ui.setup + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.dolphin.data.JellyfinServer +import com.github.damontecres.dolphin.data.JellyfinServerDao +import com.github.damontecres.dolphin.data.JellyfinUser +import com.github.damontecres.dolphin.data.ServerRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.authenticateUserByName +import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.model.api.QuickConnectDto +import org.jellyfin.sdk.model.api.QuickConnectResult +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SwitchUserViewModel + @Inject + constructor( + val jellyfin: Jellyfin, + val serverRepository: ServerRepository, + val serverDao: JellyfinServerDao, + ) : ViewModel() { + val servers = MutableLiveData>(listOf()) + val serverStatus = MutableLiveData>(mapOf()) + + val users = MutableLiveData>(listOf()) + val quickConnectEnabled = MutableLiveData(false) + val quickConnectState = MutableLiveData(null) + + private var quickConnectJob: Job? = null + + init { + viewModelScope.launch(Dispatchers.IO) { + val allServers = + serverDao + .getServers() + .map { it.server } + .sortedWith(compareBy { it.name }.thenBy { it.url }) + withContext(Dispatchers.Main) { + servers.value = allServers + } + allServers.forEach { server -> + try { + jellyfin + .createApi(server.url) + .systemApi + .getPublicSystemInfo() + withContext(Dispatchers.Main) { + serverStatus.value = + serverStatus.value!!.toMutableMap().apply { + put( + server.id, + ServerConnectionStatus.Success, + ) + } + } + } catch (ex: Exception) { + Timber.w(ex, "Error checking quick connect for server ${server.url}") + serverStatus.value = + serverStatus.value!!.toMutableMap().apply { + put( + server.id, + ServerConnectionStatus.Error(ex.localizedMessage), + ) + } + } + } + } + viewModelScope.launch(Dispatchers.IO) { + serverRepository.currentServer?.let { + val quickConnect = + jellyfin + .createApi(it.url) + .quickConnectApi + .getQuickConnectEnabled() + .content + val serverUsers = serverDao.getServer(it.id)?.users?.sortedBy { it.name } ?: listOf() + withContext(Dispatchers.Main) { + quickConnectEnabled.value = quickConnect + users.value = serverUsers + } + } + } + } + + fun switchUser( + server: JellyfinServer, + user: JellyfinUser, + callback: () -> Unit, + ) { + viewModelScope.launch { + serverRepository.changeUser(server, user) + callback.invoke() + } + } + + fun login( + server: JellyfinServer, + username: String, + password: String, + onAuthenticated: () -> Unit, + ) { + quickConnectJob?.cancel() + viewModelScope.launch { + try { + val api = jellyfin.createApi(baseUrl = server.url) + val authenticationResult by api.userApi.authenticateUserByName( + username = username, + password = password, + ) + serverRepository.changeUser(server.url, authenticationResult) + withContext(Dispatchers.Main) { + onAuthenticated.invoke() + } + } catch (err: InvalidStatusException) { + if (err.status == 401) { + // TODO set state to notify user + } + } + } + } + + fun initiateQuickConnect( + server: JellyfinServer, + onAuthenticated: () -> Unit, + ) { + quickConnectJob?.cancel() + viewModelScope.launch(Dispatchers.IO) { + try { + val api = jellyfin.createApi(server.url) + var state = + api + .quickConnectApi + .initiateQuickConnect() + .content + withContext(Dispatchers.Main) { + quickConnectState.value = state + } + + quickConnectJob = + viewModelScope.launch { + while (!state.authenticated) { + delay(5_000L) + state = + api.quickConnectApi + .getQuickConnectState( + secret = state.secret, + ).content + withContext(Dispatchers.Main) { + quickConnectState.value = state + } + } + val authenticationResult by api.userApi.authenticateWithQuickConnect( + QuickConnectDto(secret = state.secret), + ) + serverRepository.changeUser(server.url, authenticationResult) + withContext(Dispatchers.Main) { + onAuthenticated.invoke() + } + } + } catch (err: InvalidStatusException) { + if (err.status == 401) { + quickConnectState.value = null + quickConnectEnabled.value = false + } + } + } + } + + fun cancelQuickConnect() { + quickConnectJob?.cancel() + quickConnectState.value = null + } + + fun addServer( + serverUrl: String, + callback: () -> Unit, + ) { + viewModelScope.launch { + try { + val serverInfo by jellyfin + .createApi(serverUrl) + .systemApi + .getPublicSystemInfo() + val id = serverInfo.id + if (id != null) { + serverRepository.addAndChangeServer( + JellyfinServer( + id = id, + name = serverInfo.serverName, + url = serverUrl, + ), + ) + callback.invoke() + } else { + // TODO + } + } catch (ex: Exception) { + Timber.w(ex, "Error creating API for $serverUrl") + // TODO notify user + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/UserList.kt new file mode 100644 index 00000000..eaa09d41 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/UserList.kt @@ -0,0 +1,70 @@ +package com.github.damontecres.dolphin.ui.setup + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.data.JellyfinUser + +@Composable +fun UserList( + users: List, + currentUser: JellyfinUser?, + onSwitchUser: (JellyfinUser) -> Unit, + onAddUser: () -> Unit, + onRemoveUser: (JellyfinUser) -> Unit, + onSwitchServer: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + items(users) { user -> + ListItem( + enabled = true, + selected = false, + headlineContent = { Text(text = user.name ?: user.id) }, + supportingContent = { }, + leadingContent = { + if (user.id == currentUser?.id) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "current user", + ) + } + }, + onClick = { onSwitchUser.invoke(user) }, + onLongClick = { + // TODO dialog to remove user + }, + modifier = Modifier, + ) + } + item { + HorizontalDivider() + ListItem( + enabled = true, + selected = false, + headlineContent = { Text(text = "Add User") }, + supportingContent = { }, + onClick = { onAddUser.invoke() }, + modifier = Modifier, + ) + } + item { + HorizontalDivider() + ListItem( + enabled = true, + selected = false, + headlineContent = { Text(text = "Switch servers") }, + supportingContent = { }, + onClick = { onSwitchServer.invoke() }, + modifier = Modifier, + ) + } + } +}