diff --git a/app-tv/src/main/java/hu/bbara/purefin/navigation/TvRouteEntryBuilder.kt b/app-tv/src/main/java/hu/bbara/purefin/navigation/TvRouteEntryBuilder.kt index 1a4818e8..97e2b759 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/navigation/TvRouteEntryBuilder.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/navigation/TvRouteEntryBuilder.kt @@ -7,7 +7,7 @@ import hu.bbara.purefin.core.navigation.SeriesDto import hu.bbara.purefin.core.feature.browse.home.AppViewModel import hu.bbara.purefin.ui.screen.movie.TvMovieScreen import hu.bbara.purefin.ui.screen.series.TvSeriesScreen -import hu.bbara.purefin.ui.screen.login.LoginScreen +import hu.bbara.purefin.ui.screen.login.TvLoginScreen import hu.bbara.purefin.ui.screen.TvAppScreen import hu.bbara.purefin.ui.screen.library.TvLibraryScreen import hu.bbara.purefin.ui.screen.player.TvPlayerScreen @@ -21,7 +21,7 @@ fun EntryProviderScope.tvHomeSection() { fun EntryProviderScope.tvLoginSection() { entry { - LoginScreen() + TvLoginScreen() } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt index c35e48b7..de9b6629 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt @@ -52,7 +52,7 @@ import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.navigation.LocalNavigationBackStack import hu.bbara.purefin.navigation.LocalNavigationManager -import hu.bbara.purefin.ui.screen.login.LoginScreen +import hu.bbara.purefin.ui.screen.login.TvLoginScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.backgroundDark @@ -229,7 +229,7 @@ class TvActivity : ComponentActivity() { ) } } else { - LoginScreen() + TvLoginScreen() } availableUpdate?.let { update -> diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt deleted file mode 100644 index b13ec193..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt +++ /dev/null @@ -1,68 +0,0 @@ -package hu.bbara.purefin.ui.screen.login - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -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.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.core.feature.login.LoginViewModel -import kotlinx.coroutines.launch - -@Composable -fun LoginScreen( - viewModel: LoginViewModel = hiltViewModel(), - modifier: Modifier = Modifier -) { - val serverUrl by viewModel.url.collectAsState() - val username by viewModel.username.collectAsState() - val password by viewModel.password.collectAsState() - val errorMessage by viewModel.errorMessage.collectAsState() - var isLoggingIn by remember { mutableStateOf(false) } - val coroutineScope = rememberCoroutineScope() - - val state = remember(serverUrl, username, password, errorMessage) { - LoginContentState( - serverUrl = serverUrl, - username = username, - password = password, - errorMessage = errorMessage - ) - } - val callbacks = remember(viewModel, coroutineScope) { - LoginContentCallbacks( - onServerUrlChange = { - viewModel.clearError() - viewModel.setUrl(it) - }, - onUsernameChange = { - viewModel.clearError() - viewModel.setUsername(it) - }, - onPasswordChange = { - viewModel.clearError() - viewModel.setPassword(it) - }, - onConnect = { - coroutineScope.launch { - isLoggingIn = true - try { - viewModel.login() - } finally { - isLoggingIn = false - } - } - } - ) - } - - TvLoginContent( - state = state, - callbacks = callbacks, - isLoggingIn = isLoggingIn, - modifier = modifier - ) -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/TvLoginContent.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/TvLoginContent.kt index 966314e2..349f6bd8 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/TvLoginContent.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/TvLoginContent.kt @@ -4,12 +4,15 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +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.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -42,6 +45,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -56,10 +60,9 @@ import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen fun TvLoginContent( state: LoginContentState, callbacks: LoginContentCallbacks, - isLoggingIn: Boolean, modifier: Modifier = Modifier ) { - if (isLoggingIn) { + if (state.isLoggingIn) { PurefinWaitingScreen(modifier = modifier) return } @@ -120,12 +123,21 @@ private fun TvLoginForm( ) { val scheme = MaterialTheme.colorScheme val serverFocusRequester = remember { FocusRequester() } + val findFocusRequester = remember { FocusRequester() } + val changeServerFocusRequester = remember { FocusRequester() } + val quickConnectFocusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } val connectFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - serverFocusRequester.requestFocus() + LaunchedEffect(state.phase) { + if (state.phase == LoginContentPhase.ServerSearch) { + serverFocusRequester.requestFocus() + } else if (state.quickConnectAvailable) { + quickConnectFocusRequester.requestFocus() + } else { + usernameFocusRequester.requestFocus() + } } Box( @@ -137,7 +149,7 @@ private fun TvLoginForm( ) { Column( modifier = Modifier - .widthIn(max = 480.dp) + .widthIn(max = if (state.phase == LoginContentPhase.Login) 1040.dp else 480.dp) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -153,7 +165,11 @@ private fun TvLoginForm( modifier = Modifier.padding(top = 8.dp) ) Text( - text = "Connect to your media server", + text = if (state.phase == LoginContentPhase.ServerSearch) { + "Find your Jellyfin server" + } else { + "Connect to your media server" + }, color = scheme.onSurfaceVariant, style = MaterialTheme.typography.bodyLarge ) @@ -178,21 +194,223 @@ private fun TvLoginForm( Spacer(modifier = Modifier.height(10.dp)) } - TvLoginTextField( - label = "Server URL", - value = state.serverUrl, - onValueChange = callbacks.onServerUrlChange, - placeholder = "http://192.168.1.100:8096", - leadingIcon = Icons.Default.Storage, - modifier = Modifier - .focusRequester(serverFocusRequester) - .focusProperties { - down = usernameFocusRequester - } + when (state.phase) { + LoginContentPhase.ServerSearch -> TvServerSearchFields( + state = state, + callbacks = callbacks, + serverFocusRequester = serverFocusRequester, + findFocusRequester = findFocusRequester + ) + LoginContentPhase.Login -> TvLoginFields( + state = state, + callbacks = callbacks, + changeServerFocusRequester = changeServerFocusRequester, + quickConnectFocusRequester = quickConnectFocusRequester, + usernameFocusRequester = usernameFocusRequester, + passwordFocusRequester = passwordFocusRequester, + connectFocusRequester = connectFocusRequester + ) + } + } + } +} + +@Composable +private fun TvServerSearchFields( + state: LoginContentState, + callbacks: LoginContentCallbacks, + serverFocusRequester: FocusRequester, + findFocusRequester: FocusRequester +) { + val scheme = MaterialTheme.colorScheme + + TvLoginTextField( + label = "Server URL", + value = state.serverUrl, + onValueChange = callbacks.onServerUrlChange, + placeholder = "http://192.168.1.100:8096", + leadingIcon = Icons.Default.Storage, + modifier = Modifier + .focusRequester(serverFocusRequester) + .focusProperties { + down = findFocusRequester + } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = callbacks.onFindServer, + enabled = !state.isSearching, + modifier = Modifier + .focusRequester(findFocusRequester) + .focusProperties { + up = serverFocusRequester + } + .fillMaxWidth() + .height(48.dp) + ) { + TvText( + text = if (state.isSearching) "Searching..." else "Find server", + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + + if (state.discoveredServers.isNotEmpty()) { + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = "Nearby servers", + color = scheme.onBackground, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + state.discoveredServers.take(3).forEach { server -> + Button( + onClick = { callbacks.onDiscoveredServerClick(server) }, + modifier = Modifier.fillMaxWidth().height(54.dp) + ) { + TvText( + text = server.name ?: server.address, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 15.sp + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun TvLoginFields( + state: LoginContentState, + callbacks: LoginContentCallbacks, + changeServerFocusRequester: FocusRequester, + quickConnectFocusRequester: FocusRequester, + usernameFocusRequester: FocusRequester, + passwordFocusRequester: FocusRequester, + connectFocusRequester: FocusRequester +) { + val scheme = MaterialTheme.colorScheme + val selectedServer = state.selectedServerName ?: state.selectedServerUrl.orEmpty() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Selected server", + color = scheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) + Text( + text = selectedServer, + color = scheme.onBackground, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Button( + onClick = callbacks.onChangeServer, + modifier = Modifier + .focusRequester(changeServerFocusRequester) + .focusProperties { + down = if (state.quickConnectAvailable) quickConnectFocusRequester else usernameFocusRequester + } + .width(176.dp) + .height(44.dp) + ) { + TvText(text = "Change server", fontSize = 15.sp) + } + } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(18.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + TvLoginOptionPanel( + title = "Quick Connect", + modifier = Modifier.weight(1f) + ) { + if (state.quickConnectAvailable) { + val quickConnectCode = state.quickConnectCode + if (quickConnectCode != null) { + Text( + text = quickConnectCode, + color = scheme.primary, + style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "Approve this code in another Jellyfin client.", + color = scheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + } else { + Text( + text = "Use another Jellyfin client to approve this TV without entering your password.", + color = scheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + } + Button( + onClick = if (state.quickConnectCode == null) { + callbacks.onQuickConnect + } else { + callbacks.onCancelQuickConnect + }, + enabled = state.quickConnectCode != null || !state.isQuickConnecting, + modifier = Modifier + .focusRequester(quickConnectFocusRequester) + .focusProperties { + up = changeServerFocusRequester + right = usernameFocusRequester + down = usernameFocusRequester + } + .fillMaxWidth() + .height(48.dp) + ) { + TvText( + text = if (state.quickConnectCode == null) { + "Quick Connect" + } else { + "Cancel Quick Connect" + }, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } else { + Text( + text = "Quick Connect is not enabled on this server.", + color = scheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + + TvLoginOptionPanel( + title = "Manual login", + modifier = Modifier.weight(1f) + ) { TvLoginTextField( label = "Username", value = state.username, @@ -202,7 +420,12 @@ private fun TvLoginForm( modifier = Modifier .focusRequester(usernameFocusRequester) .focusProperties { - up = serverFocusRequester + up = changeServerFocusRequester + left = if (state.quickConnectAvailable) { + quickConnectFocusRequester + } else { + FocusRequester.Default + } down = passwordFocusRequester } ) @@ -223,10 +446,11 @@ private fun TvLoginForm( } ) - Spacer(modifier = Modifier.height(22.dp)) + Spacer(modifier = Modifier.height(18.dp)) Button( onClick = callbacks.onConnect, + enabled = !state.isQuickConnecting, modifier = Modifier .focusRequester(connectFocusRequester) .focusProperties { @@ -245,6 +469,37 @@ private fun TvLoginForm( } } +@Composable +private fun TvLoginOptionPanel( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val scheme = MaterialTheme.colorScheme + val shape = RoundedCornerShape(18.dp) + + Column( + modifier = modifier + .clip(shape) + .background(scheme.surface) + .border( + width = 1.dp, + color = scheme.outlineVariant.copy(alpha = 0.45f), + shape = shape + ) + .padding(22.dp) + ) { + Text( + text = title, + color = scheme.onBackground, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(18.dp)) + content() + } +} + @Composable private fun TvLoginTextField( label: String, diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/TvLoginScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/TvLoginScreen.kt new file mode 100644 index 00000000..3906d2b3 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/TvLoginScreen.kt @@ -0,0 +1,98 @@ +package hu.bbara.purefin.ui.screen.login + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.core.data.JellyfinServerCandidate +import hu.bbara.purefin.core.feature.login.LoginViewModel +import kotlinx.coroutines.launch + +@Composable +fun TvLoginScreen( + viewModel: LoginViewModel = hiltViewModel(), + modifier: Modifier = Modifier +) { + val serverUrl by viewModel.url.collectAsState() + val phase by viewModel.phase.collectAsState() + val selectedServer by viewModel.selectedServer.collectAsState() + val discoveredServers by viewModel.discoveredServers.collectAsState() + val username by viewModel.username.collectAsState() + val password by viewModel.password.collectAsState() + val isSearching by viewModel.isSearching.collectAsState() + val isLoggingIn by viewModel.isLoggingIn.collectAsState() + val quickConnectAvailable by viewModel.quickConnectAvailable.collectAsState() + val quickConnectCode by viewModel.quickConnectCode.collectAsState() + val isQuickConnecting by viewModel.isQuickConnecting.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() + val coroutineScope = rememberCoroutineScope() + + val state = remember( + phase, + serverUrl, + selectedServer, + discoveredServers, + username, + password, + isSearching, + isLoggingIn, + quickConnectAvailable, + quickConnectCode, + isQuickConnecting, + errorMessage + ) { + LoginContentState( + phase = if (phase == LoginViewModel.Phase.LOGIN) { + LoginContentPhase.Login + } else { + LoginContentPhase.ServerSearch + }, + serverUrl = serverUrl, + selectedServerName = selectedServer?.name, + selectedServerUrl = selectedServer?.address, + discoveredServers = discoveredServers.map { + LoginServerCandidateUi(name = it.name, address = it.address) + }, + username = username, + password = password, + isSearching = isSearching, + isLoggingIn = isLoggingIn, + quickConnectAvailable = quickConnectAvailable, + quickConnectCode = quickConnectCode, + isQuickConnecting = isQuickConnecting, + errorMessage = errorMessage + ) + } + val callbacks = remember(viewModel, coroutineScope) { + LoginContentCallbacks( + onServerUrlChange = viewModel::setUrl, + onFindServer = viewModel::findServer, + onDiscoveredServerClick = { + viewModel.selectDiscoveredServer(JellyfinServerCandidate(name = it.name, address = it.address)) + }, + onChangeServer = viewModel::changeServer, + onUsernameChange = { + viewModel.setUsername(it) + }, + onPasswordChange = { + viewModel.setPassword(it) + }, + onConnect = { + coroutineScope.launch { + viewModel.login() + } + }, + onQuickConnect = viewModel::startQuickConnect, + onCancelQuickConnect = viewModel::cancelQuickConnect + ) + } + + TvLoginContent( + state = state, + callbacks = callbacks, + modifier = modifier + ) +} diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginContent.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginContent.kt index 45dfa5af..40171fbd 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginContent.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginContent.kt @@ -18,10 +18,12 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import hu.bbara.purefin.ui.common.button.PurefinTextButton import hu.bbara.purefin.ui.common.image.PurefinLogo @@ -33,12 +35,11 @@ import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen fun LoginContent( state: LoginContentState, callbacks: LoginContentCallbacks, - isLoggingIn: Boolean, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme - if (isLoggingIn) { + if (state.isLoggingIn) { PurefinWaitingScreen(modifier = modifier) return } @@ -76,21 +77,6 @@ fun LoginContent( Spacer(modifier = Modifier.height(28.dp)) - Text( - text = "Connect to server", - color = scheme.onBackground, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - modifier = Modifier.align(Alignment.Start) - ) - Text( - text = "Enter your server and account details.", - color = scheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .align(Alignment.Start) - .padding(top = 2.dp, bottom = 16.dp) - ) - state.errorMessage?.let { errorMessage -> Text( text = errorMessage, @@ -107,43 +93,177 @@ fun LoginContent( Spacer(modifier = Modifier.height(12.dp)) } - PurefinComplexTextField( - label = "Server URL", - value = state.serverUrl, - onValueChange = callbacks.onServerUrlChange, - placeholder = "http://192.168.1.100:8096", - leadingIcon = Icons.Default.Storage - ) - - Spacer(modifier = Modifier.height(12.dp)) - - PurefinComplexTextField( - label = "Username", - value = state.username, - onValueChange = callbacks.onUsernameChange, - placeholder = "Enter your username", - leadingIcon = Icons.Default.Person - ) - - Spacer(modifier = Modifier.height(12.dp)) - - PurefinPasswordField( - label = "Password", - value = state.password, - onValueChange = callbacks.onPasswordChange, - placeholder = "Enter your password", - leadingIcon = Icons.Default.Lock, - ) - - Spacer(modifier = Modifier.height(24.dp)) - - PurefinTextButton( - content = { Text("Connect") }, - onClick = callbacks.onConnect, - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - ) + when (state.phase) { + LoginContentPhase.ServerSearch -> ServerSearchContent(state, callbacks) + LoginContentPhase.Login -> LoginPhaseContent(state, callbacks) + } } } } + +@Composable +private fun ServerSearchContent( + state: LoginContentState, + callbacks: LoginContentCallbacks +) { + val scheme = MaterialTheme.colorScheme + + Text( + text = "Find your server", + color = scheme.onBackground, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "Search by address or choose a nearby Jellyfin server.", + color = scheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 2.dp, bottom = 16.dp).fillMaxWidth() + ) + + PurefinComplexTextField( + label = "Server URL", + value = state.serverUrl, + onValueChange = callbacks.onServerUrlChange, + placeholder = "http://192.168.1.100:8096", + leadingIcon = Icons.Default.Storage + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PurefinTextButton( + content = { Text(if (state.isSearching) "Searching..." else "Find server") }, + onClick = callbacks.onFindServer, + enabled = !state.isSearching, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + + if (state.discoveredServers.isNotEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Nearby servers", + color = scheme.onBackground, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + state.discoveredServers.forEach { server -> + TextButton( + onClick = { callbacks.onDiscoveredServerClick(server) }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = server.name?.let { "$it\n${server.address}" } ?: server.address, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun LoginPhaseContent( + state: LoginContentState, + callbacks: LoginContentCallbacks +) { + val scheme = MaterialTheme.colorScheme + val selectedServer = state.selectedServerName ?: state.selectedServerUrl.orEmpty() + + Text( + text = "Log in", + color = scheme.onBackground, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.fillMaxWidth() + ) + Text( + text = selectedServer, + color = scheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 2.dp).fillMaxWidth() + ) + TextButton( + onClick = callbacks.onChangeServer, + modifier = Modifier.fillMaxWidth() + ) { + Text("Change server") + } + + if (state.quickConnectAvailable) { + Spacer(modifier = Modifier.height(8.dp)) + state.quickConnectCode?.let { code -> + Text( + text = code, + color = scheme.primary, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "Approve this code in another Jellyfin client.", + color = scheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + ) + Spacer(modifier = Modifier.height(10.dp)) + TextButton(onClick = callbacks.onCancelQuickConnect) { + Text("Cancel Quick Connect") + } + } ?: PurefinTextButton( + content = { Text("Quick Connect") }, + onClick = callbacks.onQuickConnect, + enabled = !state.isQuickConnecting, + modifier = Modifier.fillMaxWidth().height(48.dp) + ) + } else { + Text( + text = "Quick Connect is not enabled on this server.", + color = scheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Manual login", + color = scheme.onBackground, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + PurefinComplexTextField( + label = "Username", + value = state.username, + onValueChange = callbacks.onUsernameChange, + placeholder = "Enter your username", + leadingIcon = Icons.Default.Person + ) + + Spacer(modifier = Modifier.height(12.dp)) + + PurefinPasswordField( + label = "Password", + value = state.password, + onValueChange = callbacks.onPasswordChange, + placeholder = "Enter your password", + leadingIcon = Icons.Default.Lock, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + PurefinTextButton( + content = { Text("Connect") }, + onClick = callbacks.onConnect, + enabled = !state.isQuickConnecting, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) +} diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt index 0bea9088..d6ce3ca3 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt @@ -3,12 +3,11 @@ package hu.bbara.purefin.ui.screen.login import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.core.data.JellyfinServerCandidate import hu.bbara.purefin.core.feature.login.LoginViewModel import kotlinx.coroutines.launch @@ -18,51 +17,82 @@ fun LoginScreen( modifier: Modifier = Modifier ) { val serverUrl by viewModel.url.collectAsState() + val phase by viewModel.phase.collectAsState() + val selectedServer by viewModel.selectedServer.collectAsState() + val discoveredServers by viewModel.discoveredServers.collectAsState() val username by viewModel.username.collectAsState() val password by viewModel.password.collectAsState() + val isSearching by viewModel.isSearching.collectAsState() + val isLoggingIn by viewModel.isLoggingIn.collectAsState() + val quickConnectAvailable by viewModel.quickConnectAvailable.collectAsState() + val quickConnectCode by viewModel.quickConnectCode.collectAsState() + val isQuickConnecting by viewModel.isQuickConnecting.collectAsState() val errorMessage by viewModel.errorMessage.collectAsState() - var isLoggingIn by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() - val state = remember(serverUrl, username, password, errorMessage) { + val state = remember( + phase, + serverUrl, + selectedServer, + discoveredServers, + username, + password, + isSearching, + isLoggingIn, + quickConnectAvailable, + quickConnectCode, + isQuickConnecting, + errorMessage + ) { LoginContentState( + phase = if (phase == LoginViewModel.Phase.LOGIN) { + LoginContentPhase.Login + } else { + LoginContentPhase.ServerSearch + }, serverUrl = serverUrl, + selectedServerName = selectedServer?.name, + selectedServerUrl = selectedServer?.address, + discoveredServers = discoveredServers.map { + LoginServerCandidateUi(name = it.name, address = it.address) + }, username = username, password = password, + isSearching = isSearching, + isLoggingIn = isLoggingIn, + quickConnectAvailable = quickConnectAvailable, + quickConnectCode = quickConnectCode, + isQuickConnecting = isQuickConnecting, errorMessage = errorMessage ) } val callbacks = remember(viewModel, coroutineScope) { LoginContentCallbacks( - onServerUrlChange = { - viewModel.clearError() - viewModel.setUrl(it) + onServerUrlChange = viewModel::setUrl, + onFindServer = viewModel::findServer, + onDiscoveredServerClick = { + viewModel.selectDiscoveredServer(JellyfinServerCandidate(name = it.name, address = it.address)) }, + onChangeServer = viewModel::changeServer, onUsernameChange = { - viewModel.clearError() viewModel.setUsername(it) }, onPasswordChange = { - viewModel.clearError() viewModel.setPassword(it) }, onConnect = { coroutineScope.launch { - isLoggingIn = true - try { - viewModel.login() - } finally { - isLoggingIn = false - } + viewModel.login() } - } + }, + onQuickConnect = viewModel::startQuickConnect, + onCancelQuickConnect = viewModel::cancelQuickConnect ) } LoginContent( state = state, callbacks = callbacks, - isLoggingIn = isLoggingIn, modifier = modifier ) } diff --git a/core-ui/src/main/java/hu/bbara/purefin/ui/screen/login/LoginModels.kt b/core-ui/src/main/java/hu/bbara/purefin/ui/screen/login/LoginModels.kt index 833f8e1c..d479f3ac 100644 --- a/core-ui/src/main/java/hu/bbara/purefin/ui/screen/login/LoginModels.kt +++ b/core-ui/src/main/java/hu/bbara/purefin/ui/screen/login/LoginModels.kt @@ -2,17 +2,42 @@ package hu.bbara.purefin.ui.screen.login import androidx.compose.runtime.Immutable +enum class LoginContentPhase { + ServerSearch, + Login +} + +@Immutable +data class LoginServerCandidateUi( + val name: String?, + val address: String +) + @Immutable data class LoginContentState( + val phase: LoginContentPhase, val serverUrl: String, + val selectedServerName: String?, + val selectedServerUrl: String?, + val discoveredServers: List, val username: String, val password: String, + val isSearching: Boolean, + val isLoggingIn: Boolean, + val quickConnectAvailable: Boolean, + val quickConnectCode: String?, + val isQuickConnecting: Boolean, val errorMessage: String? = null ) class LoginContentCallbacks( val onServerUrlChange: (String) -> Unit, + val onFindServer: () -> Unit, + val onDiscoveredServerClick: (LoginServerCandidateUi) -> Unit, + val onChangeServer: () -> Unit, val onUsernameChange: (String) -> Unit, val onPasswordChange: (String) -> Unit, - val onConnect: () -> Unit + val onConnect: () -> Unit, + val onQuickConnect: () -> Unit, + val onCancelQuickConnect: () -> Unit ) diff --git a/core/src/main/java/hu/bbara/purefin/core/data/AuthenticationRepository.kt b/core/src/main/java/hu/bbara/purefin/core/data/AuthenticationRepository.kt index 9dde8ce7..c836a807 100644 --- a/core/src/main/java/hu/bbara/purefin/core/data/AuthenticationRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/core/data/AuthenticationRepository.kt @@ -1,5 +1,24 @@ package hu.bbara.purefin.core.data +import kotlinx.coroutines.flow.Flow + +data class JellyfinServerCandidate( + val name: String?, + val address: String +) + +data class QuickConnectSession( + val code: String, + val secret: String, + val authenticated: Boolean +) + interface AuthenticationRepository { + fun discoverServers(): Flow + suspend fun findServer(input: String): JellyfinServerCandidate? + suspend fun isQuickConnectEnabled(url: String): Boolean + suspend fun initiateQuickConnect(url: String): QuickConnectSession? + suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? suspend fun login(url: String, username: String, password: String): Boolean + suspend fun loginWithQuickConnect(url: String, secret: String): Boolean } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/login/LoginViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/login/LoginViewModel.kt index 532056da..95ebb6a7 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/login/LoginViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/login/LoginViewModel.kt @@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.core.data.AuthenticationRepository +import hu.bbara.purefin.core.data.JellyfinServerCandidate import hu.bbara.purefin.core.data.UserSessionRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,30 +21,57 @@ class LoginViewModel @Inject constructor( private val userSessionRepository: UserSessionRepository, private val authenticationRepository: AuthenticationRepository, ) : ViewModel() { + enum class Phase { + SERVER_SEARCH, + LOGIN + } + private val _username = MutableStateFlow("") val username: StateFlow = _username.asStateFlow() private val _password = MutableStateFlow("") val password: StateFlow = _password.asStateFlow() private val _url = MutableStateFlow("") val url: StateFlow = _url.asStateFlow() + private val _phase = MutableStateFlow(Phase.SERVER_SEARCH) + val phase: StateFlow = _phase.asStateFlow() + private val _selectedServer = MutableStateFlow(null) + val selectedServer: StateFlow = _selectedServer.asStateFlow() + private val _discoveredServers = MutableStateFlow>(emptyList()) + val discoveredServers: StateFlow> = _discoveredServers.asStateFlow() + private val _isSearching = MutableStateFlow(false) + val isSearching: StateFlow = _isSearching.asStateFlow() + private val _isLoggingIn = MutableStateFlow(false) + val isLoggingIn: StateFlow = _isLoggingIn.asStateFlow() + private val _quickConnectAvailable = MutableStateFlow(false) + val quickConnectAvailable: StateFlow = _quickConnectAvailable.asStateFlow() + private val _quickConnectCode = MutableStateFlow(null) + val quickConnectCode: StateFlow = _quickConnectCode.asStateFlow() + private val _isQuickConnecting = MutableStateFlow(false) + val isQuickConnecting: StateFlow = _isQuickConnecting.asStateFlow() private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() + private var quickConnectJob: Job? = null + init { viewModelScope.launch { _url.value = userSessionRepository.serverUrl.first() } + discoverServers() } fun setUrl(url: String) { + clearError() _url.value = url } fun setUsername(username: String) { + clearError() _username.value = username } fun setPassword(password: String) { + clearError() _password.value = password } @@ -49,19 +80,144 @@ class LoginViewModel @Inject constructor( } suspend fun clearFields() { - userSessionRepository.setServerUrl(""); + cancelQuickConnect() + userSessionRepository.setServerUrl("") + _url.value = "" _username.value = "" _password.value = "" + _selectedServer.value = null + _phase.value = Phase.SERVER_SEARCH } suspend fun login(): Boolean { + cancelQuickConnect() _errorMessage.value = null - userSessionRepository.setServerUrl(url.value) - val success = authenticationRepository.login(url.value, username.value, password.value) - if (!success) { - _errorMessage.value = "Login failed. Check your server URL, username, and password." + _isLoggingIn.value = true + return try { + val serverUrl = selectedServer.value?.address ?: url.value + userSessionRepository.setServerUrl(serverUrl) + val success = authenticationRepository.login(serverUrl, username.value, password.value) + if (!success) { + _errorMessage.value = "Login failed. Check your server URL, username, and password." + } + success + } finally { + _isLoggingIn.value = false } - return success + } + + fun findServer() { + viewModelScope.launch { + val input = url.value.trim() + if (input.isBlank()) { + _errorMessage.value = "Enter a Jellyfin server address." + return@launch + } + + cancelQuickConnect() + _isSearching.value = true + _errorMessage.value = null + val server = authenticationRepository.findServer(input) + _isSearching.value = false + if (server == null) { + _errorMessage.value = "No Jellyfin server found for that address." + return@launch + } + selectServer(server) + } + } + + fun selectDiscoveredServer(server: JellyfinServerCandidate) { + viewModelScope.launch { + cancelQuickConnect() + _errorMessage.value = null + selectServer(server) + } + } + + fun changeServer() { + cancelQuickConnect() + _errorMessage.value = null + _quickConnectAvailable.value = false + _selectedServer.value = null + _phase.value = Phase.SERVER_SEARCH + } + + fun startQuickConnect() { + val serverUrl = selectedServer.value?.address ?: url.value + if (serverUrl.isBlank()) { + _errorMessage.value = "Select a Jellyfin server first." + return + } + + quickConnectJob?.cancel() + quickConnectJob = viewModelScope.launch { + _errorMessage.value = null + _isQuickConnecting.value = true + _quickConnectCode.value = null + try { + var session = authenticationRepository.initiateQuickConnect(serverUrl) ?: run { + _errorMessage.value = "Quick Connect is not available on this server." + return@launch + } + + _quickConnectCode.value = session.code + while (!session.authenticated) { + delay(2_000L) + val updatedSession = authenticationRepository.getQuickConnectState(serverUrl, session.secret) + if (updatedSession == null) { + _errorMessage.value = "Quick Connect failed. Try again or use manual login." + return@launch + } + session = updatedSession + _quickConnectCode.value = session.code + } + + userSessionRepository.setServerUrl(serverUrl) + val success = authenticationRepository.loginWithQuickConnect(serverUrl, session.secret) + if (!success) { + _errorMessage.value = "Quick Connect login failed. Try again or use manual login." + } + } catch (_: CancellationException) { + } finally { + _isQuickConnecting.value = false + _quickConnectCode.value = null + } + } + } + + fun cancelQuickConnect() { + quickConnectJob?.cancel() + quickConnectJob = null + _isQuickConnecting.value = false + _quickConnectCode.value = null + } + + override fun onCleared() { + cancelQuickConnect() + super.onCleared() + } + + private fun discoverServers() { + viewModelScope.launch { + try { + authenticationRepository.discoverServers().collect { server -> + _discoveredServers.value = (_discoveredServers.value + server) + .distinctBy { it.address } + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + } + } + } + + private suspend fun selectServer(server: JellyfinServerCandidate) { + _selectedServer.value = server + _url.value = server.address + _phase.value = Phase.LOGIN + userSessionRepository.setServerUrl(server.address) + _quickConnectAvailable.value = authenticationRepository.isQuickConnectEnabled(server.address) } } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index f7915cda..ab348bec 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -6,18 +6,25 @@ import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import hu.bbara.purefin.core.data.PlaybackMethod import hu.bbara.purefin.core.data.PlaybackReportContext +import hu.bbara.purefin.core.data.JellyfinServerCandidate +import hu.bbara.purefin.core.data.QuickConnectSession import hu.bbara.purefin.core.data.UserSessionRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userApi @@ -26,6 +33,8 @@ import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.operations.SystemApi import org.jellyfin.sdk.createJellyfin +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.BaseItemDto @@ -90,6 +99,30 @@ class JellyfinApiClient @Inject constructor( } } + fun discoverServers(): Flow = + jellyfin.discovery.discoverLocalServers() + .map { JellyfinServerCandidate(name = it.name, address = it.address) } + .flowOn(Dispatchers.IO) + + suspend fun findServer(input: String): JellyfinServerCandidate? = withContext(Dispatchers.IO) { + logRequest("findServer") { + val address = input.trim() + if (address.isBlank()) { + return@logRequest null + } + + val recommendedServers = jellyfin.discovery.getRecommendedServers( + input = address, + minimumScore = RecommendedServerInfoScore.OK + ) + val server = recommendedServers.bestServer() ?: return@logRequest null + JellyfinServerCandidate( + name = server.systemInfo.getOrNull()?.serverName, + address = server.address + ) + } + } + suspend fun authenticate( url: String, username: String, @@ -106,6 +139,50 @@ class JellyfinApiClient @Inject constructor( } } + suspend fun isQuickConnectEnabled(url: String): Boolean = withContext(Dispatchers.IO) { + logRequest("isQuickConnectEnabled") { + val trimmedUrl = url.trim() + if (trimmedUrl.isBlank()) { + return@logRequest false + } + api.update(baseUrl = trimmedUrl) + api.quickConnectApi.getQuickConnectEnabled().content + } + } + + suspend fun initiateQuickConnect(url: String): QuickConnectSession? = withContext(Dispatchers.IO) { + logRequest("initiateQuickConnect") { + val trimmedUrl = url.trim() + if (trimmedUrl.isBlank()) { + return@logRequest null + } + api.update(baseUrl = trimmedUrl) + api.quickConnectApi.initiateQuickConnect().content.toQuickConnectSession() + } + } + + suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? = withContext(Dispatchers.IO) { + logRequest("getQuickConnectState") { + val trimmedUrl = url.trim() + if (trimmedUrl.isBlank() || secret.isBlank()) { + return@logRequest null + } + api.update(baseUrl = trimmedUrl) + api.quickConnectApi.getQuickConnectState(secret).content.toQuickConnectSession() + } + } + + suspend fun authenticateWithQuickConnect(url: String, secret: String): AuthenticationResult? = withContext(Dispatchers.IO) { + logRequest("authenticateWithQuickConnect") { + val trimmedUrl = url.trim() + if (trimmedUrl.isBlank() || secret.isBlank()) { + return@logRequest null + } + api.update(baseUrl = trimmedUrl) + api.userApi.authenticateWithQuickConnect(secret).content + } + } + suspend fun searchBySearchTerm(searchTerm: String): List = withContext(Dispatchers.IO) { logRequest("searchBySearchTerm") { if (!ensureConfigured()) { @@ -547,6 +624,18 @@ class JellyfinApiClient @Inject constructor( PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE } + private fun Collection.bestServer(): RecommendedServerInfo? = + firstOrNull { it.score == RecommendedServerInfoScore.GREAT } + ?: firstOrNull { it.score == RecommendedServerInfoScore.GOOD } + ?: firstOrNull { it.score == RecommendedServerInfoScore.OK } + + private fun org.jellyfin.sdk.model.api.QuickConnectResult.toQuickConnectSession(): QuickConnectSession = + QuickConnectSession( + code = code, + secret = secret, + authenticated = authenticated + ) + companion object { private const val TAG = "JellyfinApiClient" } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt index ab9ac332..4475dadf 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt @@ -2,8 +2,12 @@ package hu.bbara.purefin.data.jellyfin.session import android.util.Log import hu.bbara.purefin.core.data.AuthenticationRepository +import hu.bbara.purefin.core.data.JellyfinServerCandidate +import hu.bbara.purefin.core.data.QuickConnectSession import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient +import kotlinx.coroutines.flow.Flow +import org.jellyfin.sdk.model.api.AuthenticationResult import javax.inject.Inject import javax.inject.Singleton @@ -12,23 +16,61 @@ class JellyfinAuthenticationRepository @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, private val userSessionRepository: UserSessionRepository, ) : AuthenticationRepository { + override fun discoverServers(): Flow = + jellyfinApiClient.discoverServers() + + override suspend fun findServer(input: String): JellyfinServerCandidate? = + runCatching { jellyfinApiClient.findServer(input) } + .onFailure { Log.e(TAG, "Server search failed", it) } + .getOrNull() + + override suspend fun isQuickConnectEnabled(url: String): Boolean = + runCatching { jellyfinApiClient.isQuickConnectEnabled(url) } + .onFailure { Log.e(TAG, "Quick Connect check failed", it) } + .getOrDefault(false) + + override suspend fun initiateQuickConnect(url: String): QuickConnectSession? = + runCatching { jellyfinApiClient.initiateQuickConnect(url) } + .onFailure { Log.e(TAG, "Quick Connect initiation failed", it) } + .getOrNull() + + override suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? = + runCatching { jellyfinApiClient.getQuickConnectState(url, secret) } + .onFailure { Log.e(TAG, "Quick Connect state check failed", it) } + .getOrNull() + override suspend fun login(url: String, username: String, password: String): Boolean { return try { val authResult = jellyfinApiClient.authenticate(url = url, username = username, password = password) ?: return false - val token = authResult.accessToken ?: return false - val userId = authResult.user?.id ?: return false - - userSessionRepository.setAccessToken(accessToken = token) - userSessionRepository.setUserId(userId) - userSessionRepository.setLoggedIn(true) - true + saveAuthenticationResult(authResult) } catch (e: Exception) { Log.e(TAG, "Login failed", e) false } } + override suspend fun loginWithQuickConnect(url: String, secret: String): Boolean { + return try { + val authResult = jellyfinApiClient.authenticateWithQuickConnect(url = url, secret = secret) + ?: return false + saveAuthenticationResult(authResult) + } catch (e: Exception) { + Log.e(TAG, "Quick Connect login failed", e) + false + } + } + + private suspend fun saveAuthenticationResult(authResult: AuthenticationResult): Boolean { + val token = authResult.accessToken ?: return false + val userId = authResult.user?.id ?: return false + + userSessionRepository.setAccessToken(accessToken = token) + userSessionRepository.setUserId(userId) + userSessionRepository.setLoggedIn(true) + return true + } + private companion object { private const val TAG = "JellyfinAuthRepo" }